@bradygaster/squad-sdk 0.9.6-insider.2 → 0.10.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/dist/adapter/client.d.ts.map +1 -1
- package/dist/adapter/client.js +16 -3
- package/dist/adapter/client.js.map +1 -1
- package/dist/adapter/types.d.ts +6 -1
- package/dist/adapter/types.d.ts.map +1 -1
- package/dist/agents/charter-compiler.d.ts +2 -0
- package/dist/agents/charter-compiler.d.ts.map +1 -1
- package/dist/agents/charter-compiler.js +6 -1
- package/dist/agents/charter-compiler.js.map +1 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +24 -25
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/lifecycle.d.ts.map +1 -1
- package/dist/agents/lifecycle.js +11 -2
- package/dist/agents/lifecycle.js.map +1 -1
- package/dist/agents/onboarding.d.ts.map +1 -1
- package/dist/agents/onboarding.js +24 -0
- package/dist/agents/onboarding.js.map +1 -1
- package/dist/config/agent-source.d.ts.map +1 -1
- package/dist/config/agent-source.js +60 -33
- package/dist/config/agent-source.js.map +1 -1
- package/dist/config/feature-audit.js +1 -1
- package/dist/config/feature-audit.js.map +1 -1
- package/dist/config/init.d.ts +4 -0
- package/dist/config/init.d.ts.map +1 -1
- package/dist/config/init.js +177 -44
- package/dist/config/init.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/marketplace/index.d.ts +7 -0
- package/dist/marketplace/index.d.ts.map +1 -1
- package/dist/marketplace/index.js +4 -0
- package/dist/marketplace/index.js.map +1 -1
- package/dist/marketplace/plugin-manifest.d.ts +113 -0
- package/dist/marketplace/plugin-manifest.d.ts.map +1 -0
- package/dist/marketplace/plugin-manifest.js +820 -0
- package/dist/marketplace/plugin-manifest.js.map +1 -0
- package/dist/marketplace/plugin-runtime.d.ts +37 -0
- package/dist/marketplace/plugin-runtime.d.ts.map +1 -0
- package/dist/marketplace/plugin-runtime.js +217 -0
- package/dist/marketplace/plugin-runtime.js.map +1 -0
- package/dist/marketplace/plugin-state.d.ts +89 -0
- package/dist/marketplace/plugin-state.d.ts.map +1 -0
- package/dist/marketplace/plugin-state.js +278 -0
- package/dist/marketplace/plugin-state.js.map +1 -0
- package/dist/memory/index.d.ts +262 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +1122 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/multi-squad.d.ts.map +1 -1
- package/dist/multi-squad.js +5 -2
- package/dist/multi-squad.js.map +1 -1
- package/dist/platform/azure-devops.d.ts.map +1 -1
- package/dist/platform/azure-devops.js +17 -3
- package/dist/platform/azure-devops.js.map +1 -1
- package/dist/platform/detect.d.ts.map +1 -1
- package/dist/platform/detect.js +12 -5
- package/dist/platform/detect.js.map +1 -1
- package/dist/platform/index.d.ts.map +1 -1
- package/dist/platform/index.js +26 -0
- package/dist/platform/index.js.map +1 -1
- package/dist/ralph/triage.js +1 -1
- package/dist/ralph/triage.js.map +1 -1
- package/dist/resolution.d.ts +18 -0
- package/dist/resolution.d.ts.map +1 -1
- package/dist/resolution.js +64 -2
- package/dist/resolution.js.map +1 -1
- package/dist/runtime/memory-value-benchmark.d.ts +61 -0
- package/dist/runtime/memory-value-benchmark.d.ts.map +1 -0
- package/dist/runtime/memory-value-benchmark.js +245 -0
- package/dist/runtime/memory-value-benchmark.js.map +1 -0
- package/dist/runtime/scheduler.d.ts +8 -0
- package/dist/runtime/scheduler.d.ts.map +1 -1
- package/dist/runtime/scheduler.js +52 -5
- package/dist/runtime/scheduler.js.map +1 -1
- package/dist/sharing/export.d.ts +1 -0
- package/dist/sharing/export.d.ts.map +1 -1
- package/dist/sharing/export.js +10 -0
- package/dist/sharing/export.js.map +1 -1
- package/dist/sharing/import.d.ts.map +1 -1
- package/dist/sharing/import.js +3 -2
- package/dist/sharing/import.js.map +1 -1
- package/dist/sharing/index.d.ts +1 -0
- package/dist/sharing/index.d.ts.map +1 -1
- package/dist/sharing/index.js +1 -0
- package/dist/sharing/index.js.map +1 -1
- package/dist/sharing/repo-sync.d.ts +80 -0
- package/dist/sharing/repo-sync.d.ts.map +1 -0
- package/dist/sharing/repo-sync.js +138 -0
- package/dist/sharing/repo-sync.js.map +1 -0
- package/dist/state-backend.d.ts +154 -9
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +729 -184
- package/dist/state-backend.js.map +1 -1
- package/dist/tools/index.d.ts +39 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +395 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/utils/map-with-limit.d.ts +37 -0
- package/dist/utils/map-with-limit.d.ts.map +1 -0
- package/dist/utils/map-with-limit.js +81 -0
- package/dist/utils/map-with-limit.js.map +1 -0
- package/package.json +6 -2
- package/templates/after-agent-reference.md +64 -0
- package/templates/ceremony-reference.md +82 -0
- package/templates/client-compatibility-reference.md +46 -0
- package/templates/copilot-agent.md +96 -0
- package/templates/copilot-instructions.md +14 -0
- package/templates/model-selection-reference.md +101 -0
- package/templates/prd-intake.md +105 -0
- package/templates/rai-charter.md +110 -0
- package/templates/rai-policy.md +103 -0
- package/templates/ralph-reference.md +141 -0
- package/templates/routing.md +1 -0
- package/templates/scribe-charter.md +18 -151
- package/templates/session-init-reference.md +199 -0
- package/templates/skills/e2e-template-testing/SKILL.md +557 -0
- package/templates/skills/fact-checking/SKILL.md +61 -0
- package/templates/spawn-reference.md +131 -0
- package/templates/squad.agent.md.template +200 -625
- package/templates/workflow-wiring-appendix-a-code-reviewer.md +131 -0
- package/templates/workflow-wiring-appendix-b-documenter.md +140 -0
- package/templates/workflow-wiring-guide.md +276 -0
- package/templates/workflows/squad-heartbeat.yml +167 -167
- package/templates/worktree-reference.md +126 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
/**
|
|
4
|
+
* MemPalace — in-memory test double for an external spatial memory provider.
|
|
5
|
+
*
|
|
6
|
+
* Models a "memory palace" (method of loci) where memories are stored at
|
|
7
|
+
* named loci. In production this would be replaced by a real spatial/external
|
|
8
|
+
* memory service. Set `metadata.locus` on a write request to tag the
|
|
9
|
+
* destination locus; defaults to `'default'`.
|
|
10
|
+
*
|
|
11
|
+
* Accepts: LOCAL, DECISION, POLICY.
|
|
12
|
+
* Never receives FORBIDDEN, TRANSIENT, or COPILOT_MEMORY (filtered upstream).
|
|
13
|
+
*/
|
|
14
|
+
export class MemPalaceMemoryProvider {
|
|
15
|
+
maxEntries;
|
|
16
|
+
id = 'mempalace';
|
|
17
|
+
name = 'MemPalace';
|
|
18
|
+
supportedClasses = ['LOCAL', 'DECISION', 'POLICY'];
|
|
19
|
+
loci = new Map();
|
|
20
|
+
constructor(maxEntries = 500) {
|
|
21
|
+
this.maxEntries = maxEntries;
|
|
22
|
+
}
|
|
23
|
+
async status() {
|
|
24
|
+
return { id: this.id, name: this.name, available: true };
|
|
25
|
+
}
|
|
26
|
+
async write(request) {
|
|
27
|
+
const id = `mempalace-${randomUUID()}`;
|
|
28
|
+
const locus = providerPathSegment(request.metadata?.['locus'] ?? 'default');
|
|
29
|
+
this.loci.set(id, {
|
|
30
|
+
id,
|
|
31
|
+
title: request.title,
|
|
32
|
+
content: request.content,
|
|
33
|
+
class: request.classification.class,
|
|
34
|
+
loadGuidance: request.classification.loadGuidance,
|
|
35
|
+
locus,
|
|
36
|
+
path: `mempalace:${locus}:${id}`,
|
|
37
|
+
createdAt: Date.now(),
|
|
38
|
+
});
|
|
39
|
+
evictOldest(this.loci, this.maxEntries);
|
|
40
|
+
return { id, path: `mempalace:${locus}:${id}` };
|
|
41
|
+
}
|
|
42
|
+
async search(query) {
|
|
43
|
+
const normalized = query.toLowerCase();
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const entry of this.loci.values()) {
|
|
46
|
+
const score = providerRelevanceScore(entry.title, entry.content, normalized);
|
|
47
|
+
if (entry.title.toLowerCase().includes(normalized) ||
|
|
48
|
+
entry.content.toLowerCase().includes(normalized) ||
|
|
49
|
+
score > 0) {
|
|
50
|
+
results.push({
|
|
51
|
+
id: entry.id,
|
|
52
|
+
title: entry.title,
|
|
53
|
+
snippet: (entry.content.split('\n').find(l => l.toLowerCase().includes(normalized)) ?? entry.title).slice(0, 240),
|
|
54
|
+
path: entry.path,
|
|
55
|
+
class: entry.class,
|
|
56
|
+
loadGuidance: entry.loadGuidance,
|
|
57
|
+
score,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return results.sort((left, right) => (right.score ?? 0) - (left.score ?? 0) || left.id.localeCompare(right.id));
|
|
62
|
+
}
|
|
63
|
+
async delete(id) {
|
|
64
|
+
return this.loci.delete(id);
|
|
65
|
+
}
|
|
66
|
+
/** Number of stored loci entries — for test introspection only. */
|
|
67
|
+
get size() {
|
|
68
|
+
return this.loci.size;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* IndexServer — in-memory test double for a governed knowledge/instruction catalog.
|
|
73
|
+
*
|
|
74
|
+
* Models a server-side index of stable instructions and reference knowledge.
|
|
75
|
+
* In production this would be replaced by a real embedding-search or BM25 index.
|
|
76
|
+
* Set `metadata.topic` on a write request to tag the catalog entry; defaults to
|
|
77
|
+
* the memory class (lowercased).
|
|
78
|
+
*
|
|
79
|
+
* Accepts: LOCAL, DECISION, POLICY.
|
|
80
|
+
* Never receives FORBIDDEN, TRANSIENT, or COPILOT_MEMORY (filtered upstream).
|
|
81
|
+
*/
|
|
82
|
+
export class IndexServerMemoryProvider {
|
|
83
|
+
maxEntries;
|
|
84
|
+
id = 'indexserver';
|
|
85
|
+
name = 'IndexServer';
|
|
86
|
+
supportedClasses = ['LOCAL', 'DECISION', 'POLICY'];
|
|
87
|
+
catalog = new Map();
|
|
88
|
+
constructor(maxEntries = 500) {
|
|
89
|
+
this.maxEntries = maxEntries;
|
|
90
|
+
}
|
|
91
|
+
async status() {
|
|
92
|
+
return { id: this.id, name: this.name, available: true };
|
|
93
|
+
}
|
|
94
|
+
async write(request) {
|
|
95
|
+
const id = `indexserver-${randomUUID()}`;
|
|
96
|
+
const topic = providerPathSegment(request.metadata?.['topic'] ?? request.classification.class.toLowerCase());
|
|
97
|
+
this.catalog.set(id, {
|
|
98
|
+
id,
|
|
99
|
+
title: request.title,
|
|
100
|
+
content: request.content,
|
|
101
|
+
class: request.classification.class,
|
|
102
|
+
loadGuidance: request.classification.loadGuidance,
|
|
103
|
+
topic,
|
|
104
|
+
path: `indexserver:${topic}:${id}`,
|
|
105
|
+
createdAt: Date.now(),
|
|
106
|
+
});
|
|
107
|
+
evictOldest(this.catalog, this.maxEntries);
|
|
108
|
+
return { id, path: `indexserver:${topic}:${id}` };
|
|
109
|
+
}
|
|
110
|
+
async search(query) {
|
|
111
|
+
const normalized = query.toLowerCase();
|
|
112
|
+
const results = [];
|
|
113
|
+
for (const entry of this.catalog.values()) {
|
|
114
|
+
const score = providerRelevanceScore(entry.title, entry.content, normalized);
|
|
115
|
+
if (entry.title.toLowerCase().includes(normalized) ||
|
|
116
|
+
entry.content.toLowerCase().includes(normalized) ||
|
|
117
|
+
score > 0) {
|
|
118
|
+
results.push({
|
|
119
|
+
id: entry.id,
|
|
120
|
+
title: entry.title,
|
|
121
|
+
snippet: (entry.content.split('\n').find(l => l.toLowerCase().includes(normalized)) ?? entry.title).slice(0, 240),
|
|
122
|
+
path: entry.path,
|
|
123
|
+
class: entry.class,
|
|
124
|
+
loadGuidance: entry.loadGuidance,
|
|
125
|
+
score,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return results.sort((left, right) => (right.score ?? 0) - (left.score ?? 0) || left.id.localeCompare(right.id));
|
|
130
|
+
}
|
|
131
|
+
async delete(id) {
|
|
132
|
+
return this.catalog.delete(id);
|
|
133
|
+
}
|
|
134
|
+
/** Number of catalog entries — for test introspection only. */
|
|
135
|
+
get size() {
|
|
136
|
+
return this.catalog.size;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const DEFAULT_CONFIG = {
|
|
140
|
+
version: 1,
|
|
141
|
+
defaultProvider: 'local',
|
|
142
|
+
promptOnlyFallback: true,
|
|
143
|
+
externalProviders: {
|
|
144
|
+
hostInjectedCopilotAdapter: {
|
|
145
|
+
enabled: false,
|
|
146
|
+
requireApproval: true,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
policy: {
|
|
150
|
+
rejectForbidden: true,
|
|
151
|
+
rejectTransientDurableWrites: true,
|
|
152
|
+
auditContent: false,
|
|
153
|
+
auditMaxBytes: 1_048_576,
|
|
154
|
+
auditMaxArchives: 3,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
const FORBIDDEN_PATTERNS = [
|
|
158
|
+
{ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, reason: 'private key material' },
|
|
159
|
+
{ pattern: /\b(ghp|github_pat|glpat|xox[baprs])-?[A-Za-z0-9_=-]{12,}\b/i, reason: 'access token' },
|
|
160
|
+
{ pattern: /\b(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+/i, reason: 'credential-like assignment' },
|
|
161
|
+
{ pattern: /\b(AccountKey|SharedAccessKey|DefaultEndpointsProtocol)=/i, reason: 'connection string secret' },
|
|
162
|
+
{ pattern: /\b\d{3}-\d{2}-\d{4}\b/, reason: 'PII-like identifier' },
|
|
163
|
+
{ pattern: /\b(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b/, reason: 'internal network topology' },
|
|
164
|
+
{ pattern: /\b(raw logs?|stack trace|telemetry payload|dump file)\b/i, reason: 'raw diagnostic payload' },
|
|
165
|
+
{ pattern: /\b(CI|PR|build)\s+(status|failed|passed|output|log)\b/i, reason: 'transient CI/PR status' },
|
|
166
|
+
{ pattern: /\b(private|confidential|restricted)\s+customer\s+(data|record|records|details|information|info)\b/i, reason: 'private customer data' },
|
|
167
|
+
{ pattern: /\bcustomer\s+(pii|personal data|tenant secret|production data)\b/i, reason: 'private customer data' },
|
|
168
|
+
{ pattern: /\bunreviewed\s+(security\s+)?vulnerabilit(?:y|ies)\b/i, reason: 'unreviewed vulnerability disclosure' },
|
|
169
|
+
{ pattern: /\b(?:0-day|zero-day)\b/i, reason: 'unreviewed vulnerability disclosure' },
|
|
170
|
+
];
|
|
171
|
+
function cloneDefaultConfig() {
|
|
172
|
+
return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
|
173
|
+
}
|
|
174
|
+
function slugify(value) {
|
|
175
|
+
const slug = value
|
|
176
|
+
.toLowerCase()
|
|
177
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
178
|
+
.replace(/^-|-$/g, '')
|
|
179
|
+
.slice(0, 64);
|
|
180
|
+
return slug || 'memory';
|
|
181
|
+
}
|
|
182
|
+
function providerPathSegment(value) {
|
|
183
|
+
return slugify(value).slice(0, 40) || 'default';
|
|
184
|
+
}
|
|
185
|
+
function providerRelevanceScore(title, content, normalizedQuery) {
|
|
186
|
+
const queryTokens = normalizedQuery.split(/[^a-z0-9]+/).filter(token => token.length >= 4);
|
|
187
|
+
if (queryTokens.length === 0)
|
|
188
|
+
return 0;
|
|
189
|
+
const haystack = new Set(`${title} ${content}`.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
190
|
+
return queryTokens.filter(token => haystack.has(token)).length;
|
|
191
|
+
}
|
|
192
|
+
function evictOldest(entries, maxEntries) {
|
|
193
|
+
if (maxEntries < 1) {
|
|
194
|
+
entries.clear();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
while (entries.size > maxEntries) {
|
|
198
|
+
const oldest = [...entries.entries()].sort((left, right) => left[1].createdAt - right[1].createdAt)[0];
|
|
199
|
+
if (!oldest)
|
|
200
|
+
return;
|
|
201
|
+
entries.delete(oldest[0]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function safeProviderErrorReason(provider, operation, error) {
|
|
205
|
+
const errorName = error instanceof Error ? error.name : 'UnknownError';
|
|
206
|
+
return `${provider.name} provider ${operation} failed (${errorName}); raw provider error text omitted`;
|
|
207
|
+
}
|
|
208
|
+
function firstLine(value) {
|
|
209
|
+
return value.split(/\r?\n/).find(line => line.trim().length > 0)?.trim().slice(0, 80) ?? 'Untitled memory';
|
|
210
|
+
}
|
|
211
|
+
function safeAuditTitle(title, placeholder = 'Rejected governed memory') {
|
|
212
|
+
const trimmed = title?.trim();
|
|
213
|
+
if (!trimmed)
|
|
214
|
+
return placeholder;
|
|
215
|
+
return FORBIDDEN_PATTERNS.some(({ pattern }) => pattern.test(trimmed))
|
|
216
|
+
? placeholder
|
|
217
|
+
: trimmed.slice(0, 80);
|
|
218
|
+
}
|
|
219
|
+
function loadGuidanceFor(memoryClass) {
|
|
220
|
+
switch (memoryClass) {
|
|
221
|
+
case 'POLICY':
|
|
222
|
+
case 'DECISION':
|
|
223
|
+
return 'ALWAYS';
|
|
224
|
+
case 'LOCAL':
|
|
225
|
+
case 'COPILOT_MEMORY':
|
|
226
|
+
return 'ON-DEMAND';
|
|
227
|
+
case 'TRANSIENT':
|
|
228
|
+
case 'FORBIDDEN':
|
|
229
|
+
return 'NEVER';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function normalizeLoadGuidance(value, fallback) {
|
|
233
|
+
const normalized = value?.trim().replace(/^\[|\]$/g, '').toUpperCase();
|
|
234
|
+
return normalized === 'ALWAYS'
|
|
235
|
+
|| normalized === 'ON-DEMAND'
|
|
236
|
+
|| normalized === 'ARCHIVE'
|
|
237
|
+
|| normalized === 'NEVER'
|
|
238
|
+
? normalized
|
|
239
|
+
: fallback;
|
|
240
|
+
}
|
|
241
|
+
export const REAL_COPILOT_UNAVAILABLE_REASON = 'Real Copilot Memory API unavailable: no concrete callable API was found in installed @github/copilot SDK/tooling. Squad will not fake provider=copilot; use hostInjectedCopilotAdapter only when a host supplies a client.';
|
|
242
|
+
function isRealCopilotProviderSelected(config) {
|
|
243
|
+
return config.defaultProvider === 'copilot';
|
|
244
|
+
}
|
|
245
|
+
function isHostInjectedCopilotAdapterConfigured(config) {
|
|
246
|
+
return config.externalProviders.hostInjectedCopilotAdapter.enabled;
|
|
247
|
+
}
|
|
248
|
+
export class HostInjectedCopilotMemoryAdapter {
|
|
249
|
+
client;
|
|
250
|
+
constructor(client) {
|
|
251
|
+
this.client = client;
|
|
252
|
+
}
|
|
253
|
+
isAvailable() {
|
|
254
|
+
return this.client !== undefined;
|
|
255
|
+
}
|
|
256
|
+
async write(request) {
|
|
257
|
+
return this.requireClient().write(request);
|
|
258
|
+
}
|
|
259
|
+
async search(query) {
|
|
260
|
+
return this.requireClient().search(query);
|
|
261
|
+
}
|
|
262
|
+
async delete(id) {
|
|
263
|
+
return this.requireClient().delete(id);
|
|
264
|
+
}
|
|
265
|
+
requireClient() {
|
|
266
|
+
if (!this.client) {
|
|
267
|
+
throw new Error('hostInjectedCopilotAdapter is enabled, but no host-injected Copilot memory client was supplied. This is not real provider=copilot persistence.');
|
|
268
|
+
}
|
|
269
|
+
return this.client;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function destinationFor(memoryClass) {
|
|
273
|
+
switch (memoryClass) {
|
|
274
|
+
case 'LOCAL':
|
|
275
|
+
return 'local';
|
|
276
|
+
case 'DECISION':
|
|
277
|
+
return 'decision-inbox';
|
|
278
|
+
case 'POLICY':
|
|
279
|
+
return 'policy-inbox';
|
|
280
|
+
case 'COPILOT_MEMORY':
|
|
281
|
+
return 'external-semantic';
|
|
282
|
+
default:
|
|
283
|
+
return 'none';
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export async function ensureMemoryGovernanceDefaults(storage, projectRoot) {
|
|
287
|
+
const created = [];
|
|
288
|
+
const memoryDir = path.join(projectRoot, '.squad', 'memory');
|
|
289
|
+
for (const dir of ['local', 'policy-inbox', 'semantic-inbox', 'tombstones']) {
|
|
290
|
+
const fullPath = path.join(memoryDir, dir);
|
|
291
|
+
if (!await storage.exists(fullPath)) {
|
|
292
|
+
await storage.mkdir(fullPath, { recursive: true });
|
|
293
|
+
created.push(path.join('.squad', 'memory', dir));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const configPath = path.join(memoryDir, 'config.json');
|
|
297
|
+
if (!await storage.exists(configPath)) {
|
|
298
|
+
await storage.write(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
|
|
299
|
+
created.push(path.join('.squad', 'memory', 'config.json'));
|
|
300
|
+
}
|
|
301
|
+
const indexPath = path.join(memoryDir, 'index.json');
|
|
302
|
+
if (!await storage.exists(indexPath)) {
|
|
303
|
+
await storage.write(indexPath, '[]\n');
|
|
304
|
+
created.push(path.join('.squad', 'memory', 'index.json'));
|
|
305
|
+
}
|
|
306
|
+
const auditPath = path.join(memoryDir, 'audit.jsonl');
|
|
307
|
+
if (!await storage.exists(auditPath)) {
|
|
308
|
+
await storage.write(auditPath, '');
|
|
309
|
+
created.push(path.join('.squad', 'memory', 'audit.jsonl'));
|
|
310
|
+
}
|
|
311
|
+
return created;
|
|
312
|
+
}
|
|
313
|
+
export class LocalMemoryStore {
|
|
314
|
+
storage;
|
|
315
|
+
squadDir;
|
|
316
|
+
copilotProvider;
|
|
317
|
+
registeredProviders;
|
|
318
|
+
/**
|
|
319
|
+
* Async mutex tail for index read-modify-write operations.
|
|
320
|
+
* Each caller enqueues behind the current tail so concurrent writes
|
|
321
|
+
* are serialized without OS-level file locking.
|
|
322
|
+
*/
|
|
323
|
+
indexLockTail = Promise.resolve();
|
|
324
|
+
constructor(storage, rootDir, options = {}) {
|
|
325
|
+
this.storage = storage;
|
|
326
|
+
this.squadDir = options.rootKind === 'squad' ? rootDir : path.join(rootDir, '.squad');
|
|
327
|
+
this.copilotProvider = new HostInjectedCopilotMemoryAdapter(options.hostInjectedCopilotAdapterClient ?? options.copilotMemoryClient);
|
|
328
|
+
this.registeredProviders = options.registeredProviders ?? [];
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Serialize all index read-modify-write operations.
|
|
332
|
+
*
|
|
333
|
+
* All callers that do readIndex() → mutate → writeIndex() must go through
|
|
334
|
+
* this method so concurrent writes within the same store instance cannot
|
|
335
|
+
* interleave and lose entries. The critical section is purely async
|
|
336
|
+
* (no thread blocking), so this is safe in Node.js single-thread land.
|
|
337
|
+
*/
|
|
338
|
+
async withIndexLock(fn) {
|
|
339
|
+
let unlock;
|
|
340
|
+
// Append a new "release" promise to the tail of the lock chain.
|
|
341
|
+
// The next caller will wait for this release before proceeding.
|
|
342
|
+
const release = new Promise(resolve => { unlock = resolve; });
|
|
343
|
+
const prev = this.indexLockTail;
|
|
344
|
+
this.indexLockTail = release;
|
|
345
|
+
await prev;
|
|
346
|
+
try {
|
|
347
|
+
return await fn();
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
unlock();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async classify(request, options = {}) {
|
|
354
|
+
const content = request.content.trim();
|
|
355
|
+
let classification;
|
|
356
|
+
for (const { pattern, reason } of FORBIDDEN_PATTERNS) {
|
|
357
|
+
if (pattern.test(content)) {
|
|
358
|
+
classification = {
|
|
359
|
+
class: 'FORBIDDEN',
|
|
360
|
+
allowed: false,
|
|
361
|
+
reason: `Rejected as forbidden memory: ${reason}`,
|
|
362
|
+
destination: 'none',
|
|
363
|
+
loadGuidance: 'NEVER',
|
|
364
|
+
};
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (!classification) {
|
|
369
|
+
let memoryClass = request.requestedClass;
|
|
370
|
+
if (!memoryClass) {
|
|
371
|
+
if (/\b(CI|PR|build)\s+(status|failed|passed|output|log)\b/i.test(content)) {
|
|
372
|
+
memoryClass = 'TRANSIENT';
|
|
373
|
+
}
|
|
374
|
+
else if (/^\s*(always|never|must|do not)\b/i.test(content)) {
|
|
375
|
+
memoryClass = 'POLICY';
|
|
376
|
+
}
|
|
377
|
+
else if (/\b(decision|decided|adopt|standardize|use .+ for)\b/i.test(content)) {
|
|
378
|
+
memoryClass = 'DECISION';
|
|
379
|
+
}
|
|
380
|
+
else if (/\bcopilot memory|semantic memory\b/i.test(content)) {
|
|
381
|
+
memoryClass = 'COPILOT_MEMORY';
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
memoryClass = 'LOCAL';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (memoryClass === 'FORBIDDEN') {
|
|
388
|
+
classification = {
|
|
389
|
+
class: 'FORBIDDEN',
|
|
390
|
+
allowed: false,
|
|
391
|
+
reason: 'Requested class is forbidden',
|
|
392
|
+
destination: 'none',
|
|
393
|
+
loadGuidance: 'NEVER',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
else if (memoryClass === 'TRANSIENT') {
|
|
397
|
+
classification = {
|
|
398
|
+
class: 'TRANSIENT',
|
|
399
|
+
allowed: false,
|
|
400
|
+
reason: 'Transient task state is not persisted as durable memory',
|
|
401
|
+
destination: 'none',
|
|
402
|
+
loadGuidance: 'NEVER',
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
const fallbackLoadGuidance = loadGuidanceFor(memoryClass);
|
|
407
|
+
classification = {
|
|
408
|
+
class: memoryClass,
|
|
409
|
+
allowed: true,
|
|
410
|
+
reason: memoryClass === 'COPILOT_MEMORY'
|
|
411
|
+
? 'Content is allowed for governed Copilot Memory provider after opt-in checks'
|
|
412
|
+
: 'Content is allowed for governed local memory',
|
|
413
|
+
destination: destinationFor(memoryClass),
|
|
414
|
+
loadGuidance: normalizeLoadGuidance(request.metadata?.loadGuidance, fallbackLoadGuidance),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (options.audit) {
|
|
419
|
+
await this.ensureInitialized();
|
|
420
|
+
await this.audit({
|
|
421
|
+
action: 'classify',
|
|
422
|
+
class: classification.class,
|
|
423
|
+
title: safeAuditTitle(options.title, 'Classified governed memory'),
|
|
424
|
+
reason: classification.reason,
|
|
425
|
+
actor: options.actor,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return classification;
|
|
429
|
+
}
|
|
430
|
+
async write(request) {
|
|
431
|
+
await this.ensureInitialized();
|
|
432
|
+
const classification = await this.classify(request);
|
|
433
|
+
if (!classification.allowed) {
|
|
434
|
+
await this.audit({
|
|
435
|
+
action: 'reject',
|
|
436
|
+
class: classification.class,
|
|
437
|
+
title: safeAuditTitle(request.title),
|
|
438
|
+
reason: classification.reason,
|
|
439
|
+
actor: request.author,
|
|
440
|
+
});
|
|
441
|
+
return { stored: false, classification };
|
|
442
|
+
}
|
|
443
|
+
const config = await this.readConfig();
|
|
444
|
+
if (classification.class === 'COPILOT_MEMORY') {
|
|
445
|
+
if (isRealCopilotProviderSelected(config)) {
|
|
446
|
+
await this.audit({
|
|
447
|
+
action: 'reject',
|
|
448
|
+
class: classification.class,
|
|
449
|
+
title: safeAuditTitle(request.title),
|
|
450
|
+
reason: REAL_COPILOT_UNAVAILABLE_REASON,
|
|
451
|
+
actor: request.author,
|
|
452
|
+
provider: 'copilot',
|
|
453
|
+
});
|
|
454
|
+
return {
|
|
455
|
+
stored: false,
|
|
456
|
+
classification: { ...classification, allowed: false, reason: REAL_COPILOT_UNAVAILABLE_REASON },
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const copilot = config.externalProviders.hostInjectedCopilotAdapter;
|
|
460
|
+
if (!copilot.enabled) {
|
|
461
|
+
const reason = 'COPILOT_MEMORY writes are disabled unless explicitly configured with hostInjectedCopilotAdapter. Real provider=copilot is unavailable locally.';
|
|
462
|
+
await this.audit({
|
|
463
|
+
action: 'reject',
|
|
464
|
+
class: classification.class,
|
|
465
|
+
title: safeAuditTitle(request.title),
|
|
466
|
+
reason,
|
|
467
|
+
actor: request.author,
|
|
468
|
+
provider: 'hostInjectedCopilotAdapter',
|
|
469
|
+
});
|
|
470
|
+
return {
|
|
471
|
+
stored: false,
|
|
472
|
+
classification: { ...classification, allowed: false, reason },
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (copilot.requireApproval && request.approved !== true) {
|
|
476
|
+
const reason = 'Copilot Memory writes require explicit approval';
|
|
477
|
+
await this.audit({
|
|
478
|
+
action: 'reject',
|
|
479
|
+
class: classification.class,
|
|
480
|
+
title: safeAuditTitle(request.title),
|
|
481
|
+
reason,
|
|
482
|
+
actor: request.author,
|
|
483
|
+
provider: 'hostInjectedCopilotAdapter',
|
|
484
|
+
});
|
|
485
|
+
return {
|
|
486
|
+
stored: false,
|
|
487
|
+
classification: { ...classification, allowed: false, reason },
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
let providerResult;
|
|
491
|
+
try {
|
|
492
|
+
providerResult = await this.copilotProvider.write({
|
|
493
|
+
content: request.content.trim(),
|
|
494
|
+
title: request.title?.trim() || firstLine(request.content),
|
|
495
|
+
author: request.author,
|
|
496
|
+
metadata: request.metadata,
|
|
497
|
+
classification,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
502
|
+
await this.audit({
|
|
503
|
+
action: 'reject',
|
|
504
|
+
class: classification.class,
|
|
505
|
+
title: safeAuditTitle(request.title),
|
|
506
|
+
reason,
|
|
507
|
+
actor: request.author,
|
|
508
|
+
provider: 'hostInjectedCopilotAdapter',
|
|
509
|
+
});
|
|
510
|
+
return {
|
|
511
|
+
stored: false,
|
|
512
|
+
classification: { ...classification, allowed: false, reason },
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const now = new Date().toISOString();
|
|
516
|
+
const title = request.title?.trim() || firstLine(request.content);
|
|
517
|
+
const entry = {
|
|
518
|
+
id: providerResult.id,
|
|
519
|
+
class: classification.class,
|
|
520
|
+
loadGuidance: classification.loadGuidance,
|
|
521
|
+
title,
|
|
522
|
+
path: providerResult.path ?? `host-injected-copilot-adapter:${providerResult.id}`,
|
|
523
|
+
status: 'active',
|
|
524
|
+
createdAt: now,
|
|
525
|
+
updatedAt: now,
|
|
526
|
+
};
|
|
527
|
+
await this.withIndexLock(async () => {
|
|
528
|
+
const index = await this.readIndex();
|
|
529
|
+
index.push(entry);
|
|
530
|
+
await this.writeIndex(index);
|
|
531
|
+
});
|
|
532
|
+
await this.audit({
|
|
533
|
+
action: 'write',
|
|
534
|
+
id: providerResult.id,
|
|
535
|
+
class: classification.class,
|
|
536
|
+
title,
|
|
537
|
+
path: entry.path,
|
|
538
|
+
reason: classification.reason,
|
|
539
|
+
actor: request.author,
|
|
540
|
+
provider: 'hostInjectedCopilotAdapter',
|
|
541
|
+
});
|
|
542
|
+
return {
|
|
543
|
+
stored: true,
|
|
544
|
+
id: providerResult.id,
|
|
545
|
+
classification,
|
|
546
|
+
path: entry.path,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
const id = randomUUID();
|
|
550
|
+
const title = request.title?.trim() || firstLine(request.content);
|
|
551
|
+
const relativePath = this.destinationPath(classification.class, id, title, request.author);
|
|
552
|
+
const fullPath = path.join(this.squadDir, relativePath);
|
|
553
|
+
const content = this.renderMemoryFile(id, classification.class, title, request);
|
|
554
|
+
await this.storage.write(fullPath, content);
|
|
555
|
+
const now = new Date().toISOString();
|
|
556
|
+
const entry = {
|
|
557
|
+
id,
|
|
558
|
+
class: classification.class,
|
|
559
|
+
loadGuidance: classification.loadGuidance,
|
|
560
|
+
title,
|
|
561
|
+
path: path.join('.squad', relativePath),
|
|
562
|
+
status: 'active',
|
|
563
|
+
createdAt: now,
|
|
564
|
+
updatedAt: now,
|
|
565
|
+
};
|
|
566
|
+
// Serialize the read-modify-write under the index lock so concurrent
|
|
567
|
+
// calls within the same store instance cannot overwrite each other's entry.
|
|
568
|
+
await this.withIndexLock(async () => {
|
|
569
|
+
const index = await this.readIndex();
|
|
570
|
+
index.push(entry);
|
|
571
|
+
await this.writeIndex(index);
|
|
572
|
+
});
|
|
573
|
+
await this.audit({
|
|
574
|
+
action: 'write',
|
|
575
|
+
id,
|
|
576
|
+
class: classification.class,
|
|
577
|
+
title,
|
|
578
|
+
path: entry.path,
|
|
579
|
+
reason: classification.reason,
|
|
580
|
+
actor: request.author,
|
|
581
|
+
provider: 'local',
|
|
582
|
+
});
|
|
583
|
+
// Route to registered external providers that support this memory class.
|
|
584
|
+
// Governance classification has already run; FORBIDDEN and TRANSIENT never
|
|
585
|
+
// reach this point. Providers receive only safe, non-copilot content.
|
|
586
|
+
const providerWriteRequest = {
|
|
587
|
+
content: request.content.trim(),
|
|
588
|
+
title,
|
|
589
|
+
author: request.author,
|
|
590
|
+
metadata: request.metadata,
|
|
591
|
+
classification,
|
|
592
|
+
};
|
|
593
|
+
for (const provider of this.registeredProviders) {
|
|
594
|
+
if (!provider.supportedClasses.includes(classification.class))
|
|
595
|
+
continue;
|
|
596
|
+
try {
|
|
597
|
+
const providerResult = await provider.write(providerWriteRequest);
|
|
598
|
+
await this.audit({
|
|
599
|
+
action: 'write',
|
|
600
|
+
id: providerResult.id,
|
|
601
|
+
class: classification.class,
|
|
602
|
+
title,
|
|
603
|
+
path: providerResult.path ?? `${provider.id}:${providerResult.id}`,
|
|
604
|
+
reason: `Replicated to ${provider.name} provider`,
|
|
605
|
+
actor: request.author,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
await this.audit({
|
|
610
|
+
action: 'provider-error',
|
|
611
|
+
class: classification.class,
|
|
612
|
+
title,
|
|
613
|
+
reason: safeProviderErrorReason(provider, 'write', error),
|
|
614
|
+
actor: request.author,
|
|
615
|
+
provider: provider.id,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return { stored: true, id, classification, path: entry.path };
|
|
620
|
+
}
|
|
621
|
+
async search(query) {
|
|
622
|
+
await this.ensureInitialized();
|
|
623
|
+
const queryClassification = await this.classify({ content: query });
|
|
624
|
+
if (queryClassification.class === 'FORBIDDEN') {
|
|
625
|
+
await this.audit({
|
|
626
|
+
action: 'reject',
|
|
627
|
+
class: queryClassification.class,
|
|
628
|
+
title: 'Rejected governed memory search',
|
|
629
|
+
reason: queryClassification.reason,
|
|
630
|
+
});
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
const config = await this.readConfig();
|
|
634
|
+
if (isRealCopilotProviderSelected(config)) {
|
|
635
|
+
await this.audit({
|
|
636
|
+
action: 'reject',
|
|
637
|
+
class: 'COPILOT_MEMORY',
|
|
638
|
+
title: 'Rejected governed memory search',
|
|
639
|
+
reason: REAL_COPILOT_UNAVAILABLE_REASON,
|
|
640
|
+
provider: 'copilot',
|
|
641
|
+
});
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
const normalized = query.toLowerCase();
|
|
645
|
+
const index = await this.readIndex();
|
|
646
|
+
const results = [];
|
|
647
|
+
for (const entry of index.filter(item => item.status === 'active')) {
|
|
648
|
+
if (entry.class === 'COPILOT_MEMORY'
|
|
649
|
+
|| entry.path.startsWith('copilot-memory:')
|
|
650
|
+
|| entry.path.startsWith('host-injected-copilot-adapter:'))
|
|
651
|
+
continue;
|
|
652
|
+
const content = await this.storage.read(this.absoluteFromEntryPath(entry.path));
|
|
653
|
+
if (!content)
|
|
654
|
+
continue;
|
|
655
|
+
const haystack = `${entry.title}\n${content}`.toLowerCase();
|
|
656
|
+
if (!haystack.includes(normalized))
|
|
657
|
+
continue;
|
|
658
|
+
const matchLine = content.split(/\r?\n/).find(line => line.toLowerCase().includes(normalized));
|
|
659
|
+
results.push({
|
|
660
|
+
id: entry.id,
|
|
661
|
+
class: entry.class,
|
|
662
|
+
loadGuidance: entry.loadGuidance ?? loadGuidanceFor(entry.class),
|
|
663
|
+
title: entry.title,
|
|
664
|
+
path: entry.path,
|
|
665
|
+
snippet: (matchLine ?? entry.title).trim().slice(0, 240),
|
|
666
|
+
provider: 'local',
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
if (isHostInjectedCopilotAdapterConfigured(config)) {
|
|
670
|
+
const activeCopilotIds = new Set(index
|
|
671
|
+
.filter(item => item.status === 'active' && item.class === 'COPILOT_MEMORY')
|
|
672
|
+
.map(item => item.id));
|
|
673
|
+
const externalResults = await this.copilotProvider.search(query);
|
|
674
|
+
for (const result of externalResults) {
|
|
675
|
+
if (!activeCopilotIds.has(result.id))
|
|
676
|
+
continue;
|
|
677
|
+
results.push({
|
|
678
|
+
id: result.id,
|
|
679
|
+
class: 'COPILOT_MEMORY',
|
|
680
|
+
loadGuidance: 'ON-DEMAND',
|
|
681
|
+
title: result.title,
|
|
682
|
+
path: result.path ?? `host-injected-copilot-adapter:${result.id}`,
|
|
683
|
+
snippet: result.snippet.slice(0, 240),
|
|
684
|
+
provider: 'hostInjectedCopilotAdapter',
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// Query registered external providers. Governance filtering already
|
|
689
|
+
// passed. Deduplicates results by id (local takes precedence).
|
|
690
|
+
const seenIds = new Set(results.map(r => r.id));
|
|
691
|
+
for (const provider of this.registeredProviders) {
|
|
692
|
+
try {
|
|
693
|
+
const providerResults = await provider.search(query);
|
|
694
|
+
for (const result of providerResults) {
|
|
695
|
+
if (seenIds.has(result.id))
|
|
696
|
+
continue;
|
|
697
|
+
seenIds.add(result.id);
|
|
698
|
+
results.push({
|
|
699
|
+
id: result.id,
|
|
700
|
+
class: result.class,
|
|
701
|
+
loadGuidance: result.loadGuidance,
|
|
702
|
+
title: result.title,
|
|
703
|
+
path: result.path ?? `${provider.id}:${result.id}`,
|
|
704
|
+
snippet: result.snippet.slice(0, 240),
|
|
705
|
+
provider: provider.id,
|
|
706
|
+
score: result.score,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
await this.audit({
|
|
712
|
+
action: 'provider-error',
|
|
713
|
+
title: 'Registered provider search failed',
|
|
714
|
+
reason: safeProviderErrorReason(provider, 'search', error),
|
|
715
|
+
provider: provider.id,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
await this.audit({
|
|
720
|
+
action: 'search',
|
|
721
|
+
reason: `Search returned ${results.length} result(s)`,
|
|
722
|
+
});
|
|
723
|
+
return results;
|
|
724
|
+
}
|
|
725
|
+
async promote(id, targetClass, actor) {
|
|
726
|
+
await this.ensureInitialized();
|
|
727
|
+
const index = await this.readIndex();
|
|
728
|
+
const entry = index.find(item => item.id === id && item.status === 'active');
|
|
729
|
+
if (!entry) {
|
|
730
|
+
throw new Error(`Memory '${id}' not found`);
|
|
731
|
+
}
|
|
732
|
+
const content = await this.storage.read(this.absoluteFromEntryPath(entry.path));
|
|
733
|
+
if (!content) {
|
|
734
|
+
throw new Error(`Memory '${id}' content not found`);
|
|
735
|
+
}
|
|
736
|
+
const body = content.split('---').slice(2).join('---').trim() || content;
|
|
737
|
+
const result = await this.write({
|
|
738
|
+
content: body,
|
|
739
|
+
title: entry.title,
|
|
740
|
+
author: actor,
|
|
741
|
+
requestedClass: targetClass,
|
|
742
|
+
});
|
|
743
|
+
if (result.stored && result.id) {
|
|
744
|
+
const now = new Date().toISOString();
|
|
745
|
+
const successorId = result.id;
|
|
746
|
+
if (!successorId) {
|
|
747
|
+
throw new Error(`Promoted memory '${id}' did not return a successor id`);
|
|
748
|
+
}
|
|
749
|
+
// Serialize the supersedes/supersededBy mutation under the lock.
|
|
750
|
+
// write() above already released its lock before we reach here.
|
|
751
|
+
let priorEntryPath;
|
|
752
|
+
await this.withIndexLock(async () => {
|
|
753
|
+
const nextIndex = await this.readIndex();
|
|
754
|
+
const prior = nextIndex.find(item => item.id === id);
|
|
755
|
+
const successor = nextIndex.find(item => item.id === successorId);
|
|
756
|
+
if (successor) {
|
|
757
|
+
successor.supersedes = id;
|
|
758
|
+
successor.updatedAt = now;
|
|
759
|
+
}
|
|
760
|
+
if (prior) {
|
|
761
|
+
prior.status = 'superseded';
|
|
762
|
+
prior.loadGuidance = 'ARCHIVE';
|
|
763
|
+
prior.supersededBy = successorId;
|
|
764
|
+
prior.updatedAt = now;
|
|
765
|
+
priorEntryPath = prior.path;
|
|
766
|
+
}
|
|
767
|
+
await this.writeIndex(nextIndex);
|
|
768
|
+
});
|
|
769
|
+
if (priorEntryPath && !priorEntryPath.startsWith('host-injected-copilot-adapter:') && !priorEntryPath.startsWith('copilot-memory:')) {
|
|
770
|
+
await this.updateMemoryFileMetadata(priorEntryPath, {
|
|
771
|
+
status: 'superseded',
|
|
772
|
+
loadGuidance: '[ARCHIVE]',
|
|
773
|
+
supersededBy: successorId,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
await this.audit({
|
|
777
|
+
action: 'promote',
|
|
778
|
+
id,
|
|
779
|
+
class: targetClass,
|
|
780
|
+
title: entry.title,
|
|
781
|
+
reason: `Promoted to ${targetClass}`,
|
|
782
|
+
actor,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return result;
|
|
786
|
+
}
|
|
787
|
+
async delete(id, actor) {
|
|
788
|
+
await this.ensureInitialized();
|
|
789
|
+
return this.withIndexLock(async () => {
|
|
790
|
+
const index = await this.readIndex();
|
|
791
|
+
const entry = index.find(item => item.id === id && item.status !== 'deleted');
|
|
792
|
+
if (!entry)
|
|
793
|
+
return false;
|
|
794
|
+
const previousStatus = entry.status;
|
|
795
|
+
const deletedAt = new Date().toISOString();
|
|
796
|
+
const tombstonePath = path.join(this.squadDir, 'memory', 'tombstones', `${id}.json`);
|
|
797
|
+
const tombstoneData = JSON.stringify({
|
|
798
|
+
id,
|
|
799
|
+
deletedAt,
|
|
800
|
+
path: entry.path,
|
|
801
|
+
previousStatus,
|
|
802
|
+
supersedes: entry.supersedes,
|
|
803
|
+
supersededBy: entry.supersededBy,
|
|
804
|
+
loadGuidance: '[ARCHIVE]',
|
|
805
|
+
}, null, 2) + '\n';
|
|
806
|
+
if (entry.class === 'COPILOT_MEMORY'
|
|
807
|
+
|| entry.path.startsWith('copilot-memory:')
|
|
808
|
+
|| entry.path.startsWith('host-injected-copilot-adapter:')) {
|
|
809
|
+
const config = await this.readConfig();
|
|
810
|
+
if (isRealCopilotProviderSelected(config)) {
|
|
811
|
+
throw new Error(REAL_COPILOT_UNAVAILABLE_REASON);
|
|
812
|
+
}
|
|
813
|
+
if (!isHostInjectedCopilotAdapterConfigured(config)) {
|
|
814
|
+
throw new Error('COPILOT_MEMORY delete requires hostInjectedCopilotAdapter to be enabled; real provider=copilot is unavailable locally.');
|
|
815
|
+
}
|
|
816
|
+
// For COPILOT_MEMORY: gate on external delete success before local mutations.
|
|
817
|
+
const deleted = await this.copilotProvider.delete(id);
|
|
818
|
+
if (!deleted)
|
|
819
|
+
return false;
|
|
820
|
+
// Write tombstone first (local intent record), then update index.
|
|
821
|
+
await this.storage.write(tombstonePath, tombstoneData);
|
|
822
|
+
entry.status = 'deleted';
|
|
823
|
+
entry.loadGuidance = 'ARCHIVE';
|
|
824
|
+
entry.deletedAt = deletedAt;
|
|
825
|
+
entry.updatedAt = deletedAt;
|
|
826
|
+
await this.writeIndex(index);
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
// For local entries: tombstone FIRST (before any destructive action),
|
|
830
|
+
// then update index, then delete source file last.
|
|
831
|
+
// If tombstone write fails, source and index are untouched.
|
|
832
|
+
// If writeIndex fails after tombstone, source still exists and tombstone
|
|
833
|
+
// signals the delete intent for recovery.
|
|
834
|
+
await this.storage.write(tombstonePath, tombstoneData);
|
|
835
|
+
entry.status = 'deleted';
|
|
836
|
+
entry.loadGuidance = 'ARCHIVE';
|
|
837
|
+
entry.deletedAt = deletedAt;
|
|
838
|
+
entry.updatedAt = deletedAt;
|
|
839
|
+
await this.writeIndex(index);
|
|
840
|
+
// Source deletion is last: if it fails, the entry is already marked
|
|
841
|
+
// deleted in the index and a tombstone exists — recoverable.
|
|
842
|
+
await this.storage.delete(this.absoluteFromEntryPath(entry.path));
|
|
843
|
+
}
|
|
844
|
+
await this.audit({
|
|
845
|
+
action: 'delete',
|
|
846
|
+
id,
|
|
847
|
+
class: entry.class,
|
|
848
|
+
title: entry.title,
|
|
849
|
+
path: entry.path,
|
|
850
|
+
reason: 'Deleted governed memory and wrote tombstone',
|
|
851
|
+
actor,
|
|
852
|
+
provider: entry.class === 'COPILOT_MEMORY' ? 'hostInjectedCopilotAdapter' : 'local',
|
|
853
|
+
});
|
|
854
|
+
return true;
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
async providerStatus() {
|
|
858
|
+
await this.ensureInitialized();
|
|
859
|
+
const config = await this.readConfig();
|
|
860
|
+
const providerStatuses = await Promise.all(this.registeredProviders.map(p => p.status()));
|
|
861
|
+
return {
|
|
862
|
+
defaultProvider: config.defaultProvider,
|
|
863
|
+
realCopilotMemory: {
|
|
864
|
+
available: false,
|
|
865
|
+
configured: isRealCopilotProviderSelected(config),
|
|
866
|
+
reason: REAL_COPILOT_UNAVAILABLE_REASON,
|
|
867
|
+
},
|
|
868
|
+
hostInjectedCopilotAdapter: {
|
|
869
|
+
...config.externalProviders.hostInjectedCopilotAdapter,
|
|
870
|
+
clientAvailable: this.copilotProvider.isAvailable(),
|
|
871
|
+
configured: isHostInjectedCopilotAdapterConfigured(config),
|
|
872
|
+
},
|
|
873
|
+
registeredProviders: providerStatuses,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
async configureHostInjectedCopilotAdapter(options) {
|
|
877
|
+
await this.ensureInitialized();
|
|
878
|
+
const current = await this.readConfig();
|
|
879
|
+
const next = {
|
|
880
|
+
...current,
|
|
881
|
+
defaultProvider: options.defaultProvider ?? current.defaultProvider,
|
|
882
|
+
externalProviders: {
|
|
883
|
+
...current.externalProviders,
|
|
884
|
+
hostInjectedCopilotAdapter: {
|
|
885
|
+
enabled: options.enabled,
|
|
886
|
+
requireApproval: options.requireApproval ?? current.externalProviders.hostInjectedCopilotAdapter.requireApproval,
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
await this.storage.write(path.join(this.squadDir, 'memory', 'config.json'), JSON.stringify(next, null, 2) + '\n');
|
|
891
|
+
await this.audit({
|
|
892
|
+
action: 'configure',
|
|
893
|
+
reason: options.enabled
|
|
894
|
+
? 'Configured hostInjectedCopilotAdapter; this is not real provider=copilot persistence'
|
|
895
|
+
: 'Disabled hostInjectedCopilotAdapter',
|
|
896
|
+
actor: options.actor,
|
|
897
|
+
provider: 'hostInjectedCopilotAdapter',
|
|
898
|
+
});
|
|
899
|
+
return next;
|
|
900
|
+
}
|
|
901
|
+
async configureCopilotProvider(options) {
|
|
902
|
+
if (options.defaultProvider === 'copilot') {
|
|
903
|
+
throw new Error(REAL_COPILOT_UNAVAILABLE_REASON);
|
|
904
|
+
}
|
|
905
|
+
return this.configureHostInjectedCopilotAdapter({
|
|
906
|
+
enabled: options.enabled,
|
|
907
|
+
requireApproval: options.requireApproval,
|
|
908
|
+
defaultProvider: options.defaultProvider,
|
|
909
|
+
actor: options.actor,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
async auditLog() {
|
|
913
|
+
await this.ensureInitialized();
|
|
914
|
+
const content = await this.storage.read(path.join(this.squadDir, 'memory', 'audit.jsonl'));
|
|
915
|
+
if (!content)
|
|
916
|
+
return [];
|
|
917
|
+
return content
|
|
918
|
+
.split(/\r?\n/)
|
|
919
|
+
.filter(Boolean)
|
|
920
|
+
.map(line => JSON.parse(line));
|
|
921
|
+
}
|
|
922
|
+
async ensureInitialized() {
|
|
923
|
+
const memoryDir = path.join(this.squadDir, 'memory');
|
|
924
|
+
for (const dir of ['local', 'policy-inbox', 'semantic-inbox', 'tombstones']) {
|
|
925
|
+
await this.storage.mkdir(path.join(memoryDir, dir), { recursive: true });
|
|
926
|
+
}
|
|
927
|
+
const configPath = path.join(memoryDir, 'config.json');
|
|
928
|
+
if (!await this.storage.exists(configPath)) {
|
|
929
|
+
await this.storage.write(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
|
|
930
|
+
}
|
|
931
|
+
const indexPath = path.join(memoryDir, 'index.json');
|
|
932
|
+
if (!await this.storage.exists(indexPath)) {
|
|
933
|
+
await this.storage.write(indexPath, '[]\n');
|
|
934
|
+
}
|
|
935
|
+
const auditPath = path.join(memoryDir, 'audit.jsonl');
|
|
936
|
+
if (!await this.storage.exists(auditPath)) {
|
|
937
|
+
await this.storage.write(auditPath, '');
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
async readConfig() {
|
|
941
|
+
const content = await this.storage.read(path.join(this.squadDir, 'memory', 'config.json'));
|
|
942
|
+
if (!content)
|
|
943
|
+
return cloneDefaultConfig();
|
|
944
|
+
try {
|
|
945
|
+
const parsed = JSON.parse(content);
|
|
946
|
+
const defaults = cloneDefaultConfig();
|
|
947
|
+
const parsedExternalProviders = parsed.externalProviders;
|
|
948
|
+
const legacyHostInjected = parsedExternalProviders?.copilotMemory;
|
|
949
|
+
return {
|
|
950
|
+
...defaults,
|
|
951
|
+
...parsed,
|
|
952
|
+
externalProviders: {
|
|
953
|
+
...defaults.externalProviders,
|
|
954
|
+
...parsedExternalProviders,
|
|
955
|
+
hostInjectedCopilotAdapter: {
|
|
956
|
+
...defaults.externalProviders.hostInjectedCopilotAdapter,
|
|
957
|
+
...(legacyHostInjected
|
|
958
|
+
? {
|
|
959
|
+
enabled: legacyHostInjected.enabled ?? defaults.externalProviders.hostInjectedCopilotAdapter.enabled,
|
|
960
|
+
requireApproval: legacyHostInjected.requireApproval ?? defaults.externalProviders.hostInjectedCopilotAdapter.requireApproval,
|
|
961
|
+
}
|
|
962
|
+
: {}),
|
|
963
|
+
...parsedExternalProviders?.hostInjectedCopilotAdapter,
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
policy: {
|
|
967
|
+
...defaults.policy,
|
|
968
|
+
...parsed.policy,
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
return cloneDefaultConfig();
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
async readIndex() {
|
|
977
|
+
const indexPath = path.join(this.squadDir, 'memory', 'index.json');
|
|
978
|
+
const content = await this.storage.read(indexPath);
|
|
979
|
+
if (!content)
|
|
980
|
+
return [];
|
|
981
|
+
let parsed;
|
|
982
|
+
try {
|
|
983
|
+
parsed = JSON.parse(content);
|
|
984
|
+
}
|
|
985
|
+
catch (err) {
|
|
986
|
+
// Do NOT silently reset to []. Backup the corrupt file and surface the error.
|
|
987
|
+
await this.backupCorruptIndex(indexPath, content);
|
|
988
|
+
throw new Error(`Memory index is corrupt (JSON parse failed: ${err instanceof Error ? err.message : String(err)}). ` +
|
|
989
|
+
`A backup was written to ${indexPath}.corrupt. Manual recovery is required.`);
|
|
990
|
+
}
|
|
991
|
+
if (!Array.isArray(parsed)) {
|
|
992
|
+
await this.backupCorruptIndex(indexPath, content);
|
|
993
|
+
throw new Error(`Memory index is corrupt (root is not an array). ` +
|
|
994
|
+
`A backup was written to ${indexPath}.corrupt. Manual recovery is required.`);
|
|
995
|
+
}
|
|
996
|
+
return parsed;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Best-effort backup of a corrupt index file.
|
|
1000
|
+
* Writes the raw content to a `.corrupt` path alongside the index.
|
|
1001
|
+
* Failure to write the backup is suppressed so callers see the original error.
|
|
1002
|
+
*/
|
|
1003
|
+
async backupCorruptIndex(indexPath, content) {
|
|
1004
|
+
try {
|
|
1005
|
+
await this.storage.write(`${indexPath}.corrupt`, content);
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
// Suppress backup write failure; caller will still throw the original error.
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
async writeIndex(index) {
|
|
1012
|
+
const indexPath = path.join(this.squadDir, 'memory', 'index.json');
|
|
1013
|
+
const tmpPath = `${indexPath}.tmp`;
|
|
1014
|
+
// Write to a temp file first, then atomically rename into place.
|
|
1015
|
+
// This prevents a crash mid-write from leaving a partial/corrupt index.
|
|
1016
|
+
await this.storage.write(tmpPath, JSON.stringify(index, null, 2) + '\n');
|
|
1017
|
+
await this.storage.rename(tmpPath, indexPath);
|
|
1018
|
+
}
|
|
1019
|
+
async audit(record) {
|
|
1020
|
+
await this.rotateAuditIfNeeded();
|
|
1021
|
+
const auditRecord = {
|
|
1022
|
+
timestamp: new Date().toISOString(),
|
|
1023
|
+
...record,
|
|
1024
|
+
};
|
|
1025
|
+
await this.storage.append(path.join(this.squadDir, 'memory', 'audit.jsonl'), JSON.stringify(auditRecord) + '\n');
|
|
1026
|
+
}
|
|
1027
|
+
async rotateAuditIfNeeded() {
|
|
1028
|
+
const config = await this.readConfig();
|
|
1029
|
+
const maxBytes = config.policy.auditMaxBytes;
|
|
1030
|
+
if (maxBytes <= 0)
|
|
1031
|
+
return;
|
|
1032
|
+
const auditPath = path.join(this.squadDir, 'memory', 'audit.jsonl');
|
|
1033
|
+
const stats = await this.storage.stat(auditPath);
|
|
1034
|
+
if (!stats || stats.size < maxBytes)
|
|
1035
|
+
return;
|
|
1036
|
+
const maxArchives = Math.max(0, config.policy.auditMaxArchives);
|
|
1037
|
+
for (let index = maxArchives; index >= 1; index--) {
|
|
1038
|
+
const current = path.join(this.squadDir, 'memory', `audit.${index}.jsonl`);
|
|
1039
|
+
const next = path.join(this.squadDir, 'memory', `audit.${index + 1}.jsonl`);
|
|
1040
|
+
if (!await this.storage.exists(current))
|
|
1041
|
+
continue;
|
|
1042
|
+
if (index === maxArchives) {
|
|
1043
|
+
await this.storage.delete(current);
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
await this.storage.rename(current, next);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (maxArchives > 0) {
|
|
1050
|
+
await this.storage.rename(auditPath, path.join(this.squadDir, 'memory', 'audit.1.jsonl'));
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
await this.storage.delete(auditPath);
|
|
1054
|
+
}
|
|
1055
|
+
await this.storage.write(auditPath, '');
|
|
1056
|
+
}
|
|
1057
|
+
destinationPath(memoryClass, id, title, author) {
|
|
1058
|
+
const prefix = author ? `${slugify(author)}-` : '';
|
|
1059
|
+
const fileName = `${prefix}${slugify(title)}-${id.slice(0, 8)}.md`;
|
|
1060
|
+
if (memoryClass === 'DECISION') {
|
|
1061
|
+
return path.join('decisions', 'inbox', fileName);
|
|
1062
|
+
}
|
|
1063
|
+
if (memoryClass === 'POLICY') {
|
|
1064
|
+
return path.join('memory', 'policy-inbox', fileName);
|
|
1065
|
+
}
|
|
1066
|
+
return path.join('memory', 'local', fileName);
|
|
1067
|
+
}
|
|
1068
|
+
renderMemoryFile(id, memoryClass, title, request) {
|
|
1069
|
+
const metadata = request.metadata ? JSON.stringify(request.metadata) : '{}';
|
|
1070
|
+
return [
|
|
1071
|
+
'---',
|
|
1072
|
+
`id: ${id}`,
|
|
1073
|
+
`class: ${memoryClass}`,
|
|
1074
|
+
`loadGuidance: [${normalizeLoadGuidance(request.metadata?.loadGuidance, loadGuidanceFor(memoryClass))}]`,
|
|
1075
|
+
`title: ${JSON.stringify(title)}`,
|
|
1076
|
+
`author: ${JSON.stringify(request.author ?? 'unknown')}`,
|
|
1077
|
+
`createdAt: ${new Date().toISOString()}`,
|
|
1078
|
+
`metadata: ${metadata}`,
|
|
1079
|
+
'---',
|
|
1080
|
+
'',
|
|
1081
|
+
request.content.trim(),
|
|
1082
|
+
'',
|
|
1083
|
+
].join('\n');
|
|
1084
|
+
}
|
|
1085
|
+
absoluteFromEntryPath(entryPath) {
|
|
1086
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(entryPath)) {
|
|
1087
|
+
throw new Error(`External memory path cannot be resolved locally: ${entryPath}`);
|
|
1088
|
+
}
|
|
1089
|
+
const normalizedInput = entryPath.replace(/\\/g, path.sep);
|
|
1090
|
+
const relative = normalizedInput.startsWith('.squad')
|
|
1091
|
+
? normalizedInput.slice('.squad'.length + 1)
|
|
1092
|
+
: normalizedInput;
|
|
1093
|
+
const normalized = path.normalize(relative);
|
|
1094
|
+
if (path.isAbsolute(normalized) || normalized === '..' || normalized.startsWith(`..${path.sep}`)) {
|
|
1095
|
+
throw new Error(`Unsafe memory path blocked: ${entryPath}`);
|
|
1096
|
+
}
|
|
1097
|
+
return path.join(this.squadDir, normalized);
|
|
1098
|
+
}
|
|
1099
|
+
async updateMemoryFileMetadata(entryPath, updates) {
|
|
1100
|
+
const fullPath = this.absoluteFromEntryPath(entryPath);
|
|
1101
|
+
const content = await this.storage.read(fullPath);
|
|
1102
|
+
if (!content)
|
|
1103
|
+
return;
|
|
1104
|
+
const lines = content.split(/\r?\n/);
|
|
1105
|
+
if (lines[0] !== '---')
|
|
1106
|
+
return;
|
|
1107
|
+
const endIndex = lines.findIndex((line, index) => index > 0 && line === '---');
|
|
1108
|
+
if (endIndex < 0)
|
|
1109
|
+
return;
|
|
1110
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
1111
|
+
const existingIndex = lines.findIndex((line, index) => index > 0 && index < endIndex && line.startsWith(`${key}:`));
|
|
1112
|
+
if (existingIndex >= 0) {
|
|
1113
|
+
lines[existingIndex] = `${key}: ${value}`;
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
lines.splice(endIndex, 0, `${key}: ${value}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
await this.storage.write(fullPath, lines.join('\n'));
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
//# sourceMappingURL=index.js.map
|