@openagents-org/agent-launcher 0.2.165 → 0.2.167
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/package.json +1 -1
- package/src/adapters/amp.js +3 -0
- package/src/adapters/base.js +219 -0
- package/src/adapters/claude.js +41 -102
- package/src/adapters/codex.js +3 -0
- package/src/adapters/copilot.js +3 -0
- package/src/adapters/decision-log.js +53 -11
- package/src/adapters/gemini.js +7 -0
- package/src/adapters/goose.js +13 -1
- package/src/adapters/hermes.js +3 -0
- package/src/adapters/llm-direct.js +3 -0
- package/src/adapters/opencode.js +3 -0
- package/src/adapters/workspace-prompt.js +174 -40
- package/src/cli.js +15 -7
- package/src/daemon.js +90 -26
- package/src/node-config.js +69 -0
- package/src/registry.js +4 -1
- package/src/workspace-client.js +4 -0
package/package.json
CHANGED
package/src/adapters/amp.js
CHANGED
|
@@ -48,6 +48,8 @@ class AmpAdapter extends BaseAdapter {
|
|
|
48
48
|
constructor(opts) {
|
|
49
49
|
super(opts);
|
|
50
50
|
this.disabledModules = opts.disabledModules || new Set();
|
|
51
|
+
// Pin the channel's decision log + glossary into the system prompt.
|
|
52
|
+
this._usesPinnedContext = true;
|
|
51
53
|
|
|
52
54
|
// channel -> Amp thread id (for `amp threads continue <id>`)
|
|
53
55
|
this._channelThreads = {};
|
|
@@ -181,6 +183,7 @@ class AmpAdapter extends BaseAdapter {
|
|
|
181
183
|
mode: this._mode,
|
|
182
184
|
disabledModules: this.disabledModules,
|
|
183
185
|
browserEnabled,
|
|
186
|
+
...this.pinnedPromptOpts(channelName),
|
|
184
187
|
});
|
|
185
188
|
}
|
|
186
189
|
|
package/src/adapters/base.js
CHANGED
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
|
|
21
21
|
const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
|
|
22
22
|
const { defaultAgentWorkdir } = require('../paths');
|
|
23
|
+
const {
|
|
24
|
+
decisionLogTitle,
|
|
25
|
+
glossaryTitle,
|
|
26
|
+
WORKSPACE_GLOSSARY_TITLE,
|
|
27
|
+
pickEntryByTitle,
|
|
28
|
+
} = require('./decision-log');
|
|
23
29
|
const {
|
|
24
30
|
REASON,
|
|
25
31
|
classifyJoinError,
|
|
@@ -85,6 +91,22 @@ class BaseAdapter {
|
|
|
85
91
|
// workspace flag must reconnect/restart to pick up the change (matches
|
|
86
92
|
// the Python adapter behavior in workspace_prompt.py).
|
|
87
93
|
this._browserEnabledCache = null;
|
|
94
|
+
// ── Knowledge pinning (decision log + glossary) ──
|
|
95
|
+
// Entry-id caches make the steady state a single GET per pinned entry;
|
|
96
|
+
// reads happen on EVERY message, so they get a hard short deadline
|
|
97
|
+
// instead of the client's default 15s — a workspace hiccup must cost the
|
|
98
|
+
// turn a couple of seconds, not half a minute.
|
|
99
|
+
this._decisionEntryIds = {}; // channel → knowledge entry id of its decision log
|
|
100
|
+
this._glossaryEntryIds = {}; // channel → knowledge entry id of its glossary
|
|
101
|
+
this._DECISION_FETCH_TIMEOUT_MS = 2000;
|
|
102
|
+
this._warnedKnowledgeDisabled = false;
|
|
103
|
+
this._pinnedStatusWarned = new Set(); // channels already told pinning is inactive
|
|
104
|
+
// Subclasses whose sync prompt builders consume pinnedPromptOpts() set
|
|
105
|
+
// this true; the channel worker then refreshes _pinnedContext before
|
|
106
|
+
// every _handleMessage call. Adapters with their own pinning flow
|
|
107
|
+
// (Claude) fetch directly instead and leave it false.
|
|
108
|
+
this._usesPinnedContext = false;
|
|
109
|
+
this._pinnedContext = {}; // channel → { decisions, glossary }
|
|
88
110
|
// Wall-clock timestamp of adapter init, used by the `status` control
|
|
89
111
|
// action to report uptime back to the channel. Reset on reinstantiation
|
|
90
112
|
// (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
|
|
@@ -747,6 +769,7 @@ class BaseAdapter {
|
|
|
747
769
|
async _channelWorker(channel, msg) {
|
|
748
770
|
this._channelBusy.add(channel);
|
|
749
771
|
try {
|
|
772
|
+
await this._prefetchPinnedContext(channel);
|
|
750
773
|
await this._handleMessage(msg);
|
|
751
774
|
} catch (e) {
|
|
752
775
|
this._log(`Error in channel worker for ${channel}: ${e.message}`);
|
|
@@ -762,6 +785,8 @@ class BaseAdapter {
|
|
|
762
785
|
try { await this.sendStatus(channel, 'processing queued message', { queue_id: nextMsg._queueId, queue_status: 'processed' }); } catch {}
|
|
763
786
|
}
|
|
764
787
|
try {
|
|
788
|
+
// Pinned entries may have changed while this message waited.
|
|
789
|
+
await this._prefetchPinnedContext(channel);
|
|
765
790
|
await this._handleMessage(nextMsg);
|
|
766
791
|
} catch (e) {
|
|
767
792
|
this._log(`Error processing queued message in ${channel}: ${e.message}`);
|
|
@@ -771,6 +796,200 @@ class BaseAdapter {
|
|
|
771
796
|
this._channelBusy.delete(channel);
|
|
772
797
|
}
|
|
773
798
|
|
|
799
|
+
// ------------------------------------------------------------------
|
|
800
|
+
// Knowledge pinning (decision log + glossary)
|
|
801
|
+
// ------------------------------------------------------------------
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Read one pinned knowledge entry with a short per-request deadline
|
|
805
|
+
* (worst case one list + one get, ~2x that budget). `titles` is tried in
|
|
806
|
+
* precedence order against a single listing; `cache` maps channel → entry
|
|
807
|
+
* id. Returns { available, state, entryId, content, error }:
|
|
808
|
+
* - available=false → knowledge module disabled, pinning inactive
|
|
809
|
+
* - state 'found' → the entry exists (entryId set; content null when a
|
|
810
|
+
* transient error hid it this turn)
|
|
811
|
+
* - state 'absent' → a successful listing confirmed no entry exists
|
|
812
|
+
* - state 'unknown' → fetch failed before existence could be confirmed;
|
|
813
|
+
* the prompt must NOT claim the entry is missing, or a
|
|
814
|
+
* recovered network mid-turn produces a duplicate
|
|
815
|
+
* - error=true → transient failure; callers must NOT treat this as
|
|
816
|
+
* "entry changed/empty"
|
|
817
|
+
* The matched entry id is cached per channel so the steady state is a
|
|
818
|
+
* single GET; a 404 or a soft-deleted entry (the backend keeps deleted
|
|
819
|
+
* rows readable by id with status "deleted") invalidates the cache and
|
|
820
|
+
* falls back to list+match, which only sees active entries.
|
|
821
|
+
*/
|
|
822
|
+
async _fetchPinnedEntry(channel, titles, cache, label) {
|
|
823
|
+
if ((this.disabledModules || new Set()).has('knowledge')) {
|
|
824
|
+
if (!this._warnedKnowledgeDisabled) {
|
|
825
|
+
this._warnedKnowledgeDisabled = true;
|
|
826
|
+
this._log(
|
|
827
|
+
'Knowledge module is disabled — decision-log pinning is INACTIVE. ' +
|
|
828
|
+
'Confirmed decisions will not survive context compaction or session resets.'
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
if (!this._pinnedStatusWarned.has(channel)) {
|
|
832
|
+
this._pinnedStatusWarned.add(channel);
|
|
833
|
+
try {
|
|
834
|
+
await this.sendStatus(
|
|
835
|
+
channel,
|
|
836
|
+
'Decision pinning inactive: the knowledge module is disabled for ' +
|
|
837
|
+
'this agent, so confirmed decisions and the shared glossary ' +
|
|
838
|
+
'cannot be pinned across session resets.'
|
|
839
|
+
);
|
|
840
|
+
} catch {}
|
|
841
|
+
}
|
|
842
|
+
return { available: false, state: 'unknown', entryId: null, title: null, content: null, error: false };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const timeout = this._DECISION_FETCH_TIMEOUT_MS;
|
|
846
|
+
// Only highest-precedence matches are ever cached (see below), so a
|
|
847
|
+
// cached id always belongs to titles[0].
|
|
848
|
+
const cachedId = cache[channel];
|
|
849
|
+
if (cachedId) {
|
|
850
|
+
try {
|
|
851
|
+
const entry = await this.client.getKnowledge(this.workspaceId, this.token, cachedId, { timeout });
|
|
852
|
+
if (entry && entry.status && entry.status !== 'active') {
|
|
853
|
+
// Soft-deleted: still served by id, but dead for updates.
|
|
854
|
+
delete cache[channel];
|
|
855
|
+
// Fall through to list+match below.
|
|
856
|
+
} else if (entry && typeof entry.title === 'string' && entry.title !== titles[0]) {
|
|
857
|
+
// Renamed (e.g. archived): the id no longer owns the canonical
|
|
858
|
+
// title, so the pin must re-resolve — a fallback or a replacement
|
|
859
|
+
// entry has to win over the stale cache.
|
|
860
|
+
delete cache[channel];
|
|
861
|
+
// Fall through to list+match below.
|
|
862
|
+
} else {
|
|
863
|
+
return { available: true, state: 'found', entryId: cachedId, title: titles[0], content: (entry && entry.content) || '', error: false };
|
|
864
|
+
}
|
|
865
|
+
} catch (e) {
|
|
866
|
+
if (/not found|HTTP 404/i.test(e.message || '')) {
|
|
867
|
+
delete cache[channel];
|
|
868
|
+
// Entry is gone — fall through to list+match below.
|
|
869
|
+
} else {
|
|
870
|
+
this._log(`${label} read failed (${e.message}) — reusing last known state`);
|
|
871
|
+
return { available: true, state: 'found', entryId: cachedId, title: titles[0], content: null, error: true };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
try {
|
|
877
|
+
const data = await this.client.listKnowledge(this.workspaceId, this.token, { limit: 500, timeout });
|
|
878
|
+
const entries = (data && data.entries) || [];
|
|
879
|
+
let entry = null;
|
|
880
|
+
let matchedTitle = null;
|
|
881
|
+
for (const title of titles) {
|
|
882
|
+
const picked = pickEntryByTitle(entries, title);
|
|
883
|
+
if (picked.duplicates > 0) {
|
|
884
|
+
this._log(
|
|
885
|
+
`${label} WARNING for ${channel} — ${picked.duplicates + 1} knowledge entries share the title ` +
|
|
886
|
+
`"${title}"; using the earliest. The duplicates should be merged manually.`
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
if (picked.entry) { entry = picked.entry; matchedTitle = title; break; }
|
|
890
|
+
}
|
|
891
|
+
if (!entry) return { available: true, state: 'absent', entryId: null, title: null, content: null, error: false };
|
|
892
|
+
// Cache ONLY a highest-precedence match. Caching a fallback (e.g. the
|
|
893
|
+
// workspace glossary) under the channel key would let the cached-id
|
|
894
|
+
// fast path skip the precedence check forever, permanently masking a
|
|
895
|
+
// channel-specific entry created later. Fallback users pay one extra
|
|
896
|
+
// list per message instead.
|
|
897
|
+
if (matchedTitle === titles[0]) cache[channel] = entry.id;
|
|
898
|
+
const full = await this.client.getKnowledge(this.workspaceId, this.token, entry.id, { timeout });
|
|
899
|
+
if (full && full.status && full.status !== 'active') {
|
|
900
|
+
// Deleted between the listing and the read.
|
|
901
|
+
delete cache[channel];
|
|
902
|
+
return { available: true, state: 'absent', entryId: null, title: null, content: null, error: false };
|
|
903
|
+
}
|
|
904
|
+
return { available: true, state: 'found', entryId: entry.id, title: matchedTitle, content: (full && full.content) || '', error: false };
|
|
905
|
+
} catch (e) {
|
|
906
|
+
this._log(`${label} fetch failed (${e.message}) — reusing last known state`);
|
|
907
|
+
const knownId = cache[channel] || null;
|
|
908
|
+
return { available: true, state: knownId ? 'found' : 'unknown', entryId: knownId, title: knownId ? titles[0] : null, content: null, error: true };
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Read the channel's decision log. See _fetchPinnedEntry for the contract. */
|
|
913
|
+
async _fetchDecisionLog(channel) {
|
|
914
|
+
return this._fetchPinnedEntry(channel, [decisionLogTitle(channel)], this._decisionEntryIds, 'Decision log');
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Read the channel's glossary — the channel-specific entry when it exists,
|
|
919
|
+
* else the workspace-wide fallback. See _fetchPinnedEntry for the contract.
|
|
920
|
+
* The result carries scope 'channel' | 'workspace' (null when not found)
|
|
921
|
+
* so prompts can treat the shared fallback as read-only.
|
|
922
|
+
*/
|
|
923
|
+
async _fetchGlossary(channel) {
|
|
924
|
+
const res = await this._fetchPinnedEntry(
|
|
925
|
+
channel,
|
|
926
|
+
[glossaryTitle(channel), WORKSPACE_GLOSSARY_TITLE],
|
|
927
|
+
this._glossaryEntryIds,
|
|
928
|
+
'Glossary'
|
|
929
|
+
);
|
|
930
|
+
res.scope = res.title === WORKSPACE_GLOSSARY_TITLE ? 'workspace'
|
|
931
|
+
: res.title ? 'channel' : null;
|
|
932
|
+
return res;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Fetch everything pinnable for a channel: { decisions, glossary }. */
|
|
936
|
+
async _fetchPinnedContext(channel) {
|
|
937
|
+
const decisions = await this._fetchDecisionLog(channel);
|
|
938
|
+
const glossary = await this._fetchGlossary(channel);
|
|
939
|
+
return { decisions, glossary };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Refresh the pinned context ahead of _handleMessage for adapters that
|
|
944
|
+
* opted in (_usesPinnedContext). A transient read failure must not wipe
|
|
945
|
+
* the last successful pin — prompt-per-turn adapters would lose the
|
|
946
|
+
* authoritative definitions for that turn — so an errored entry keeps the
|
|
947
|
+
* previous good result; a thrown fetch keeps the previous context whole.
|
|
948
|
+
*/
|
|
949
|
+
async _prefetchPinnedContext(channel) {
|
|
950
|
+
if (!this._usesPinnedContext) return;
|
|
951
|
+
try {
|
|
952
|
+
const fresh = await this._fetchPinnedContext(channel);
|
|
953
|
+
const prev = this._pinnedContext[channel];
|
|
954
|
+
if (prev) {
|
|
955
|
+
if (fresh.decisions && fresh.decisions.error && prev.decisions && !prev.decisions.error) {
|
|
956
|
+
fresh.decisions = prev.decisions;
|
|
957
|
+
}
|
|
958
|
+
if (fresh.glossary && fresh.glossary.error && prev.glossary && !prev.glossary.error) {
|
|
959
|
+
fresh.glossary = prev.glossary;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
this._pinnedContext[channel] = fresh;
|
|
963
|
+
} catch (e) {
|
|
964
|
+
this._log(`Pinned-context fetch failed (${e && e.message ? e.message : e}) — reusing the last pinned context`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Prompt-builder options ({ decisionLog, glossary }) derived from the last
|
|
970
|
+
* prefetch for the channel. Sync, so prompt builders can call it inline;
|
|
971
|
+
* returns {} when nothing was prefetched or nothing is usable.
|
|
972
|
+
*/
|
|
973
|
+
pinnedPromptOpts(channel) {
|
|
974
|
+
const ctx = this._pinnedContext[channel];
|
|
975
|
+
if (!ctx) return {};
|
|
976
|
+
const opts = {};
|
|
977
|
+
const d = ctx.decisions;
|
|
978
|
+
if (d && d.available) {
|
|
979
|
+
opts.decisionLog = {
|
|
980
|
+
enabled: true,
|
|
981
|
+
state: d.state,
|
|
982
|
+
entryId: d.entryId,
|
|
983
|
+
content: d.error ? '' : (d.content || ''),
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
const g = ctx.glossary;
|
|
987
|
+
if (g && g.available && g.state === 'found' && g.content) {
|
|
988
|
+
opts.glossary = { enabled: true, entryId: g.entryId, content: g.content, scope: g.scope || 'channel' };
|
|
989
|
+
}
|
|
990
|
+
return opts;
|
|
991
|
+
}
|
|
992
|
+
|
|
774
993
|
// ------------------------------------------------------------------
|
|
775
994
|
// Auto-title helper
|
|
776
995
|
// ------------------------------------------------------------------
|
package/src/adapters/claude.js
CHANGED
|
@@ -19,7 +19,7 @@ const { execSync, spawn } = require('child_process');
|
|
|
19
19
|
const BaseAdapter = require('./base');
|
|
20
20
|
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
21
21
|
const { buildClaudeSystemPrompt, buildClaudeSkillMd, workspaceSkillName } = require('./workspace-prompt');
|
|
22
|
-
const {
|
|
22
|
+
const { pinnedFingerprint, sampleRecap } = require('./decision-log');
|
|
23
23
|
const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
|
|
24
24
|
|
|
25
25
|
const IS_WINDOWS = process.platform === 'win32';
|
|
@@ -99,12 +99,9 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
99
99
|
// a new message starts processing in the channel.
|
|
100
100
|
this._stopNoticeSent = new Set();
|
|
101
101
|
this._persistentProcs = {}; // channel → { proc, lineBuffer, pendingLines, idleTimer, messageResolve }
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// cost the turn a couple of seconds, not half a minute.
|
|
106
|
-
this._DECISION_FETCH_TIMEOUT_MS = 2000;
|
|
107
|
-
this._warnedKnowledgeDisabled = false;
|
|
102
|
+
// Knowledge pinning (decision log + glossary) lives in BaseAdapter; this
|
|
103
|
+
// adapter fetches directly in _handleMessage because the result also
|
|
104
|
+
// drives the persistent-process staleness check.
|
|
108
105
|
this._IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
|
109
106
|
this._WATCHDOG_INTERVAL_MS = 15_000; // 15s between checks
|
|
110
107
|
this._WATCHDOG_MAX_TIMEOUTS = 20; // 20 * 15s = 5 min of silence → kill
|
|
@@ -315,85 +312,6 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
315
312
|
);
|
|
316
313
|
}
|
|
317
314
|
|
|
318
|
-
/**
|
|
319
|
-
* Read the channel's decision log (a knowledge entry) with a short
|
|
320
|
-
* per-request deadline (worst case one list + one get, ~2x that budget).
|
|
321
|
-
* Returns { available, state, entryId, content, error }:
|
|
322
|
-
* - available=false → knowledge module disabled, pinning inactive
|
|
323
|
-
* - state 'found' → the entry exists (entryId set; content null when a
|
|
324
|
-
* transient error hid it this turn)
|
|
325
|
-
* - state 'absent' → a successful listing confirmed no entry exists
|
|
326
|
-
* - state 'unknown' → fetch failed before existence could be confirmed;
|
|
327
|
-
* the prompt must NOT claim the log is missing, or a
|
|
328
|
-
* recovered network mid-turn produces a duplicate
|
|
329
|
-
* - error=true → transient failure; callers must NOT treat this as
|
|
330
|
-
* "log changed/empty"
|
|
331
|
-
* The matched entry id is cached per channel so the steady state is a
|
|
332
|
-
* single GET; a 404 or a soft-deleted entry (the backend keeps deleted
|
|
333
|
-
* rows readable by id with status "deleted") invalidates the cache and
|
|
334
|
-
* falls back to list+match, which only sees active entries.
|
|
335
|
-
*/
|
|
336
|
-
async _fetchDecisionLog(channel) {
|
|
337
|
-
if (this.disabledModules.has('knowledge')) {
|
|
338
|
-
if (!this._warnedKnowledgeDisabled) {
|
|
339
|
-
this._warnedKnowledgeDisabled = true;
|
|
340
|
-
this._log(
|
|
341
|
-
'Knowledge module is disabled — decision-log pinning is INACTIVE. ' +
|
|
342
|
-
'Confirmed decisions will not survive context compaction or session resets.'
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
return { available: false, state: 'unknown', entryId: null, content: null, error: false };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const timeout = this._DECISION_FETCH_TIMEOUT_MS;
|
|
349
|
-
const cachedId = this._decisionEntryIds[channel];
|
|
350
|
-
if (cachedId) {
|
|
351
|
-
try {
|
|
352
|
-
const entry = await this.client.getKnowledge(this.workspaceId, this.token, cachedId, { timeout });
|
|
353
|
-
if (entry && entry.status && entry.status !== 'active') {
|
|
354
|
-
// Soft-deleted: still served by id, but dead for updates.
|
|
355
|
-
delete this._decisionEntryIds[channel];
|
|
356
|
-
// Fall through to list+match below.
|
|
357
|
-
} else {
|
|
358
|
-
return { available: true, state: 'found', entryId: cachedId, content: (entry && entry.content) || '', error: false };
|
|
359
|
-
}
|
|
360
|
-
} catch (e) {
|
|
361
|
-
if (/not found|HTTP 404/i.test(e.message || '')) {
|
|
362
|
-
delete this._decisionEntryIds[channel];
|
|
363
|
-
// Entry is gone — fall through to list+match below.
|
|
364
|
-
} else {
|
|
365
|
-
this._log(`Decision log read failed (${e.message}) — reusing last known state`);
|
|
366
|
-
return { available: true, state: 'found', entryId: cachedId, content: null, error: true };
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
try {
|
|
372
|
-
const data = await this.client.listKnowledge(this.workspaceId, this.token, { limit: 500, timeout });
|
|
373
|
-
const entries = (data && data.entries) || [];
|
|
374
|
-
const { entry, duplicates } = pickDecisionEntry(entries, channel);
|
|
375
|
-
if (duplicates > 0) {
|
|
376
|
-
this._log(
|
|
377
|
-
`Decision log WARNING for ${channel} — ${duplicates + 1} knowledge entries share the title ` +
|
|
378
|
-
`"${decisionLogTitle(channel)}"; using the earliest. The duplicates should be merged manually.`
|
|
379
|
-
);
|
|
380
|
-
}
|
|
381
|
-
if (!entry) return { available: true, state: 'absent', entryId: null, content: null, error: false };
|
|
382
|
-
this._decisionEntryIds[channel] = entry.id;
|
|
383
|
-
const full = await this.client.getKnowledge(this.workspaceId, this.token, entry.id, { timeout });
|
|
384
|
-
if (full && full.status && full.status !== 'active') {
|
|
385
|
-
// Deleted between the listing and the read.
|
|
386
|
-
delete this._decisionEntryIds[channel];
|
|
387
|
-
return { available: true, state: 'absent', entryId: null, content: null, error: false };
|
|
388
|
-
}
|
|
389
|
-
return { available: true, state: 'found', entryId: entry.id, content: (full && full.content) || '', error: false };
|
|
390
|
-
} catch (e) {
|
|
391
|
-
this._log(`Decision log fetch failed (${e.message}) — reusing last known state`);
|
|
392
|
-
const knownId = this._decisionEntryIds[channel] || null;
|
|
393
|
-
return { available: true, state: knownId ? 'found' : 'unknown', entryId: knownId, content: null, error: true };
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
315
|
/**
|
|
398
316
|
* Post "Execution stopped by user." at most once per channel for a given
|
|
399
317
|
* stop. The control-action handler and the in-flight message handler both
|
|
@@ -530,7 +448,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
530
448
|
return null;
|
|
531
449
|
}
|
|
532
450
|
|
|
533
|
-
_buildClaudeCmd(prompt, channelName, { skipResume = false, browserEnabled = false, decisionLog = null } = {}) {
|
|
451
|
+
_buildClaudeCmd(prompt, channelName, { skipResume = false, browserEnabled = false, decisionLog = null, glossary = null } = {}) {
|
|
534
452
|
const claudeBin = this._findClaudeBinary();
|
|
535
453
|
if (!claudeBin) {
|
|
536
454
|
throw new Error('claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash');
|
|
@@ -547,6 +465,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
547
465
|
browserEnabled,
|
|
548
466
|
toolMode: this.toolMode,
|
|
549
467
|
decisionLog,
|
|
468
|
+
glossary,
|
|
550
469
|
});
|
|
551
470
|
|
|
552
471
|
const cmd = [claudeBin, '-p', prompt, '--output-format', 'stream-json', '--verbose'];
|
|
@@ -637,6 +556,12 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
637
556
|
mcpTools.push(`${pfx}workspace_image_search`);
|
|
638
557
|
mcpWriteTools.push(`${pfx}workspace_image_save`);
|
|
639
558
|
}
|
|
559
|
+
if (!this.disabledModules.has('knowledge')) {
|
|
560
|
+
// The decision-log/glossary protocol instructs these by name — they
|
|
561
|
+
// must be callable or the pinned knowledge silently stops updating.
|
|
562
|
+
mcpTools.push(`${pfx}workspace_list_knowledge`, `${pfx}workspace_read_knowledge`);
|
|
563
|
+
mcpWriteTools.push(`${pfx}workspace_write_knowledge`);
|
|
564
|
+
}
|
|
640
565
|
if (!this.disabledModules.has('browser')) {
|
|
641
566
|
mcpTools.push(
|
|
642
567
|
`${pfx}workspace_fetch_url`,
|
|
@@ -679,6 +604,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
679
604
|
];
|
|
680
605
|
if (this.disabledModules.has('files')) mcpArgs.push('--disable-files');
|
|
681
606
|
if (this.disabledModules.has('browser')) mcpArgs.push('--disable-browser');
|
|
607
|
+
if (this.disabledModules.has('knowledge')) mcpArgs.push('--disable-knowledge');
|
|
682
608
|
|
|
683
609
|
// Resolve the MCP server entry point
|
|
684
610
|
let mcpCommand = this._findNodeBin();
|
|
@@ -1205,17 +1131,26 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1205
1131
|
|
|
1206
1132
|
await this.sendStatus(msgChannel, 'thinking...');
|
|
1207
1133
|
|
|
1208
|
-
// Read the channel's decision log up front
|
|
1209
|
-
// check and any (re)spawn below both need it.
|
|
1134
|
+
// Read the channel's pinned knowledge (decision log + glossary) up front
|
|
1135
|
+
// — the fast-path staleness check and any (re)spawn below both need it.
|
|
1210
1136
|
const decisions = await this._fetchDecisionLog(msgChannel);
|
|
1211
|
-
|
|
1137
|
+
const glossary = await this._fetchGlossary(msgChannel);
|
|
1138
|
+
// null fingerprint = a fetch failed, current state unknown; never treat
|
|
1212
1139
|
// that as a change (it would churn respawns on every workspace hiccup).
|
|
1213
|
-
// The fingerprint covers entry
|
|
1214
|
-
// prompt, and a recreated entry can change id without changing content.
|
|
1215
|
-
const
|
|
1140
|
+
// The fingerprint covers entry ids AND content — both are pinned into
|
|
1141
|
+
// the prompt, and a recreated entry can change id without changing content.
|
|
1142
|
+
const pinnedHash = (decisions.error || glossary.error)
|
|
1143
|
+
? null
|
|
1144
|
+
: pinnedFingerprint([
|
|
1145
|
+
{ entryId: decisions.entryId, content: decisions.content },
|
|
1146
|
+
{ entryId: glossary.entryId, content: glossary.content },
|
|
1147
|
+
]);
|
|
1216
1148
|
const decisionLogOpt = decisions.available
|
|
1217
1149
|
? { enabled: true, state: decisions.state, entryId: decisions.entryId, content: decisions.error ? '' : (decisions.content || '') }
|
|
1218
1150
|
: null;
|
|
1151
|
+
const glossaryOpt = (glossary.available && glossary.state === 'found' && glossary.content)
|
|
1152
|
+
? { enabled: true, entryId: glossary.entryId, content: glossary.content, scope: glossary.scope || 'channel' }
|
|
1153
|
+
: null;
|
|
1219
1154
|
|
|
1220
1155
|
// ── Persistent process fast-path ──
|
|
1221
1156
|
// If we have a living persistent process for this channel, send via stdin
|
|
@@ -1226,17 +1161,17 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1226
1161
|
// system prompt AND the CLI permission flags (plan mode is read-only,
|
|
1227
1162
|
// execute skips permissions) — so a process from the other mode can
|
|
1228
1163
|
// never be reused. This check is deliberately independent of the
|
|
1229
|
-
//
|
|
1164
|
+
// pinned fingerprint: a failed knowledge fetch must not keep a
|
|
1230
1165
|
// read-only plan process serving execute requests.
|
|
1231
1166
|
const modeStale = existingPP.spawnMode !== this._mode;
|
|
1232
|
-
const
|
|
1233
|
-
if (modeStale ||
|
|
1167
|
+
const pinnedStale = pinnedHash !== null && existingPP.pinnedHash !== pinnedHash;
|
|
1168
|
+
if (modeStale || pinnedStale) {
|
|
1234
1169
|
// Kill it and fall through to a fresh spawn: --resume keeps the
|
|
1235
1170
|
// conversation (it lives in the CLI transcript), while the new spawn
|
|
1236
|
-
// carries the current mode and re-pins the current
|
|
1171
|
+
// carries the current mode and re-pins the current knowledge.
|
|
1237
1172
|
this._log(modeStale
|
|
1238
1173
|
? `Mode changed to ${this._mode} for ${msgChannel} — respawning with resume`
|
|
1239
|
-
: `
|
|
1174
|
+
: `Pinned knowledge changed for ${msgChannel} — respawning with resume to re-pin it`);
|
|
1240
1175
|
await this._killPersistentProc(msgChannel);
|
|
1241
1176
|
} else {
|
|
1242
1177
|
this._log(`Reusing persistent process for ${msgChannel}`);
|
|
@@ -1302,6 +1237,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1302
1237
|
skipResume: attempt > 0,
|
|
1303
1238
|
browserEnabled,
|
|
1304
1239
|
decisionLog: decisionLogOpt,
|
|
1240
|
+
glossary: glossaryOpt,
|
|
1305
1241
|
});
|
|
1306
1242
|
cmd = built.cmd;
|
|
1307
1243
|
mcpConfigFile = built.mcpConfigFile;
|
|
@@ -1313,11 +1249,14 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1313
1249
|
try {
|
|
1314
1250
|
const pp = this._spawnPersistentProc(msgChannel, cmd, cleanEnv);
|
|
1315
1251
|
// Remember the spawn-time configuration so the fast-path can detect
|
|
1316
|
-
// staleness on later messages — the
|
|
1252
|
+
// staleness on later messages — the pinned knowledge and the mode
|
|
1317
1253
|
// (which fixes both the system prompt and the permission flags).
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1254
|
+
// When the fetch errored (pinnedHash null) hash what was actually
|
|
1255
|
+
// pinned, so the next successful fetch triggers a re-pin.
|
|
1256
|
+
pp.pinnedHash = pinnedHash !== null ? pinnedHash : pinnedFingerprint([
|
|
1257
|
+
{ entryId: decisionLogOpt && decisionLogOpt.entryId, content: decisionLogOpt && decisionLogOpt.content },
|
|
1258
|
+
{ entryId: glossaryOpt && glossaryOpt.entryId, content: glossaryOpt && glossaryOpt.content },
|
|
1259
|
+
]);
|
|
1321
1260
|
pp.spawnMode = this._mode;
|
|
1322
1261
|
this._log(`Spawned persistent process for ${msgChannel} (attempt ${attempt + 1})`);
|
|
1323
1262
|
|
package/src/adapters/codex.js
CHANGED
|
@@ -34,6 +34,8 @@ class CodexAdapter extends BaseAdapter {
|
|
|
34
34
|
constructor(opts) {
|
|
35
35
|
super(opts);
|
|
36
36
|
this.disabledModules = opts.disabledModules || new Set();
|
|
37
|
+
// Pin the channel's decision log + glossary into the system prompt.
|
|
38
|
+
this._usesPinnedContext = true;
|
|
37
39
|
|
|
38
40
|
const env = this.agentEnv || process.env;
|
|
39
41
|
this._directApiKey = env.OPENAI_API_KEY || '';
|
|
@@ -174,6 +176,7 @@ class CodexAdapter extends BaseAdapter {
|
|
|
174
176
|
token: this.token,
|
|
175
177
|
mode: this._mode,
|
|
176
178
|
disabledModules: this.disabledModules,
|
|
179
|
+
...this.pinnedPromptOpts(channelName),
|
|
177
180
|
});
|
|
178
181
|
const skillsSection = this._buildInstalledSkillsSection();
|
|
179
182
|
return skillsSection ? `${base}\n\n${skillsSection}` : base;
|
package/src/adapters/copilot.js
CHANGED
|
@@ -82,6 +82,8 @@ class CopilotAdapter extends BaseAdapter {
|
|
|
82
82
|
constructor(opts) {
|
|
83
83
|
super(opts);
|
|
84
84
|
this.disabledModules = opts.disabledModules || new Set();
|
|
85
|
+
// Pin the channel's decision log + glossary into the system prompt.
|
|
86
|
+
this._usesPinnedContext = true;
|
|
85
87
|
|
|
86
88
|
const env = this.agentEnv || process.env;
|
|
87
89
|
// Official model env var (verified via `copilot help environment`).
|
|
@@ -325,6 +327,7 @@ class CopilotAdapter extends BaseAdapter {
|
|
|
325
327
|
token: this.token,
|
|
326
328
|
mode: this._mode,
|
|
327
329
|
disabledModules: this.disabledModules,
|
|
330
|
+
...this.pinnedPromptOpts(channelName),
|
|
328
331
|
});
|
|
329
332
|
}
|
|
330
333
|
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure helpers for
|
|
2
|
+
* Pure helpers for knowledge pinning ("constraint pinning").
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Two kinds of knowledge entries get pinned into agent prompts:
|
|
5
|
+
* - The decision log: a per-channel entry recording every decision the user
|
|
6
|
+
* has confirmed.
|
|
7
|
+
* - The glossary: a per-channel (or workspace-wide) entry defining shared
|
|
8
|
+
* terms and fields, so agents collaborating on the same spec cannot drift
|
|
9
|
+
* into different readings of the same name.
|
|
10
|
+
*
|
|
11
|
+
* Adapters re-read the pinned entries on every message and inject them into
|
|
12
|
+
* the prompt — the only position that survives both auto-compaction and a
|
|
13
|
+
* session reset. Everything here is side-effect free so it can be unit
|
|
14
|
+
* tested without an adapter instance.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
17
|
'use strict';
|
|
@@ -27,6 +33,20 @@ function decisionLogTitle(channelName) {
|
|
|
27
33
|
return `Decisions for channel ${channelName}`;
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Canonical knowledge-entry title for a channel's glossary. Same exact-title
|
|
38
|
+
* matching rules as the decision log.
|
|
39
|
+
*/
|
|
40
|
+
function glossaryTitle(channelName) {
|
|
41
|
+
return `Glossary for channel ${channelName}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Workspace-wide glossary fallback: used when a channel has no glossary of
|
|
46
|
+
* its own, so one shared entry can serve every channel.
|
|
47
|
+
*/
|
|
48
|
+
const WORKSPACE_GLOSSARY_TITLE = 'Workspace glossary';
|
|
49
|
+
|
|
30
50
|
/**
|
|
31
51
|
* Stable hash of the decision log content. null/undefined and '' hash
|
|
32
52
|
* identically so "no entry yet" never reads as a change.
|
|
@@ -49,13 +69,26 @@ function decisionFingerprint(entryId, content) {
|
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
72
|
+
* Fingerprint of EVERYTHING pinned into a spawned process's prompt — an
|
|
73
|
+
* ordered list of { entryId, content } pairs (decision log first, then the
|
|
74
|
+
* glossary). Any id or content change in any pinned entry changes the
|
|
75
|
+
* fingerprint, which is what triggers a respawn-with-resume to re-pin.
|
|
76
|
+
*/
|
|
77
|
+
function pinnedFingerprint(parts) {
|
|
78
|
+
const h = crypto.createHash('sha256');
|
|
79
|
+
for (const p of parts || []) {
|
|
80
|
+
h.update(decisionFingerprint(p && p.entryId, p && p.content), 'utf-8');
|
|
81
|
+
}
|
|
82
|
+
return h.digest('hex');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pick the entry with an exact title match from a knowledge listing;
|
|
87
|
+
* earliest created_at wins (concurrent creates produce duplicates — the
|
|
88
|
+
* earliest is the one other turns have been updating).
|
|
55
89
|
* Returns { entry, duplicates } where duplicates counts the extra matches.
|
|
56
90
|
*/
|
|
57
|
-
function
|
|
58
|
-
const title = decisionLogTitle(channelName);
|
|
91
|
+
function pickEntryByTitle(entries, title) {
|
|
59
92
|
const matches = (entries || []).filter((e) => e && e.title === title);
|
|
60
93
|
if (matches.length === 0) return { entry: null, duplicates: 0 };
|
|
61
94
|
const sorted = matches.slice().sort((a, b) => {
|
|
@@ -68,6 +101,11 @@ function pickDecisionEntry(entries, channelName) {
|
|
|
68
101
|
return { entry: sorted[0], duplicates: matches.length - 1 };
|
|
69
102
|
}
|
|
70
103
|
|
|
104
|
+
/** Back-compat wrapper: pick a channel's decision-log entry by its title. */
|
|
105
|
+
function pickDecisionEntry(entries, channelName) {
|
|
106
|
+
return pickEntryByTitle(entries, decisionLogTitle(channelName));
|
|
107
|
+
}
|
|
108
|
+
|
|
71
109
|
/**
|
|
72
110
|
* Fit the decision log into a character budget without ever cutting a line
|
|
73
111
|
* in half. Over budget, whole lines are kept from the head (earliest
|
|
@@ -170,10 +208,14 @@ function sampleRecap(headMsgs, tailMsgs, currentMessage, { headKeep = 5, tailKee
|
|
|
170
208
|
module.exports = {
|
|
171
209
|
PINNED_DECISIONS_MAX_CHARS,
|
|
172
210
|
RECAP_LINE_MAX_CHARS,
|
|
211
|
+
WORKSPACE_GLOSSARY_TITLE,
|
|
173
212
|
decisionLogTitle,
|
|
213
|
+
glossaryTitle,
|
|
174
214
|
hashDecisions,
|
|
175
215
|
decisionFingerprint,
|
|
216
|
+
pinnedFingerprint,
|
|
176
217
|
pickDecisionEntry,
|
|
218
|
+
pickEntryByTitle,
|
|
177
219
|
renderPinnedDecisions,
|
|
178
220
|
isRecapEligible,
|
|
179
221
|
formatRecapLine,
|