Skip to content

Commit 60b74be

Browse files
committed
chore: commit remaining workflow memory changes
1 parent d2112e3 commit 60b74be

7 files changed

Lines changed: 126 additions & 40 deletions

File tree

‎mcp/memory-server.js‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,16 +278,33 @@ const PROJECT_NAMESPACE_INPUT_SCHEMA = Object.freeze({
278278
pattern: PROJECT_NAMESPACE_SCHEMA_PATTERN
279279
})
280280

281+
const CONTEXT_READ_BINDING_REQUEST_FIELDS = Object.freeze([
282+
'schemaVersion',
283+
'contextEpoch',
284+
'planId',
285+
'planContentId',
286+
'activeRoot',
287+
'project'
288+
])
289+
const CONTEXT_READ_BINDING_ROUNDTRIP_FIELDS = Object.freeze([
290+
...CONTEXT_READ_BINDING_REQUEST_FIELDS,
291+
'bindingStatus',
292+
'verificationMode'
293+
])
294+
const CONTEXT_READ_BINDING_ROUNDTRIP_FIELD_SET = new Set(CONTEXT_READ_BINDING_ROUNDTRIP_FIELDS)
295+
281296
const CONTEXT_READ_BINDING_SCHEMA = {
282297
type: 'object',
283-
required: ['schemaVersion', 'contextEpoch', 'planId', 'planContentId', 'activeRoot', 'project'],
298+
required: CONTEXT_READ_BINDING_REQUEST_FIELDS,
284299
properties: {
285300
schemaVersion: { const: 'ContextReadBindingV1' },
286301
contextEpoch: { type: 'string', minLength: 1 },
287302
planId: { type: 'string', minLength: 1 },
288303
planContentId: { type: 'string', minLength: 1 },
289304
activeRoot: { type: 'string', minLength: 1 },
290-
project: { type: 'string' }
305+
project: { type: 'string' },
306+
bindingStatus: { type: 'string', enum: ['verified'] },
307+
verificationMode: { type: 'string', enum: ['request-bound'] }
291308
},
292309
additionalProperties: false
293310
}
@@ -640,7 +657,7 @@ const TOOLS = [
640657
limit: { type: 'integer', minimum: 1, maximum: 20, description: '最多返回会话数,默认 1' },
641658
handoffOnly: { type: 'boolean', description: '仅返回 ContextHandoffCard' },
642659
maxChars: { type: 'integer', minimum: 1, maximum: 50000, description: '正文总字符预算,默认 12000' },
643-
cursor: { type: 'string', minLength: 1, maxLength: 8192, description: '上一页返回的 opaque MemoryCursorV1;必须与同一 tool/target/context/query/source 完全匹配' },
660+
cursor: { type: 'string', minLength: 1, maxLength: 8192 },
644661
contextBinding: CONTEXT_READ_BINDING_SCHEMA
645662
}
646663
}
@@ -659,7 +676,7 @@ const TOOLS = [
659676
status: { type: 'string', enum: ['active', 'completed', 'blocked', 'unresolved', 'all'], description: '默认 active' },
660677
limit: { type: 'integer', minimum: 1, maximum: 50, description: '最多返回行数,默认 5,最大 50' },
661678
since: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: '只返回该日期及之后的行' },
662-
cursor: { type: 'string', minLength: 1, maxLength: 8192, description: '上一页返回的 opaque MemoryCursorV1;必须与同一 tool/target/context/query/source 完全匹配' },
679+
cursor: { type: 'string', minLength: 1, maxLength: 8192 },
663680
contextBinding: CONTEXT_READ_BINDING_SCHEMA
664681
}
665682
}
@@ -2158,15 +2175,16 @@ function resolveContextReadBinding(binding, target, sourceId) {
21582175
'CONTEXT_BINDING_INVALID'
21592176
)
21602177
}
2161-
const allowed = new Set(['schemaVersion', 'contextEpoch', 'planId', 'planContentId', 'activeRoot', 'project'])
2162-
const unknown = Object.keys(binding).filter(key => !allowed.has(key))
2178+
const unknown = Object.keys(binding).filter(key => !CONTEXT_READ_BINDING_ROUNDTRIP_FIELD_SET.has(key))
21632179
const requiredStrings = ['contextEpoch', 'planId', 'planContentId', 'activeRoot']
21642180
if (unknown.length || binding.schemaVersion !== 'ContextReadBindingV1' ||
21652181
requiredStrings.some(field => typeof binding[field] !== 'string' || !binding[field].trim()) ||
2166-
typeof binding.project !== 'string') {
2182+
typeof binding.project !== 'string' ||
2183+
(binding.bindingStatus !== undefined && binding.bindingStatus !== 'verified') ||
2184+
(binding.verificationMode !== undefined && binding.verificationMode !== 'request-bound')) {
21672185
throw memoryQueryError(
21682186
'contextBinding does not match the published ContextReadBindingV1 request schema.',
2169-
'Pass only schemaVersion/contextEpoch/planId/planContentId/activeRoot/project from the current plan.',
2187+
'Pass the current ContextReadBindingV1. Verified round-trip metadata is accepted; arbitrary extra fields are not.',
21702188
'CONTEXT_BINDING_INVALID'
21712189
)
21722190
}

‎mcp/profile-server.js‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,24 @@ const EXECUTION_OPTIMIZATION_BINDING_SCHEMA = {
182182
description: 'Copy the exact ExecutionOptimizationPlanBindingV1 returned by the plan.'
183183
}
184184

185+
const CONTEXT_READ_BINDING_REQUEST_FIELDS = Object.freeze([
186+
'schemaVersion',
187+
'contextEpoch',
188+
'planId',
189+
'planContentId',
190+
'activeRoot',
191+
'project'
192+
])
193+
const CONTEXT_READ_BINDING_ROUNDTRIP_FIELDS = Object.freeze([
194+
...CONTEXT_READ_BINDING_REQUEST_FIELDS,
195+
'bindingStatus',
196+
'verificationMode'
197+
])
198+
const CONTEXT_READ_BINDING_ROUNDTRIP_FIELD_SET = new Set(CONTEXT_READ_BINDING_ROUNDTRIP_FIELDS)
199+
185200
const CONTEXT_READ_BINDING_SCHEMA = {
186201
type: 'object',
187-
required: ['schemaVersion', 'contextEpoch', 'planId', 'planContentId', 'activeRoot', 'project'],
202+
required: CONTEXT_READ_BINDING_REQUEST_FIELDS,
188203
properties: {
189204
schemaVersion: { const: 'ContextReadBindingV1' },
190205
contextEpoch: { type: 'string', minLength: 1, maxLength: 256 },
@@ -196,7 +211,9 @@ const CONTEXT_READ_BINDING_SCHEMA = {
196211
minLength: 1,
197212
maxLength: 255,
198213
pattern: PROJECT_NAMESPACE_SCHEMA_PATTERN
199-
}
214+
},
215+
bindingStatus: { type: 'string', enum: ['verified'] },
216+
verificationMode: { type: 'string', enum: ['request-bound'] }
200217
},
201218
additionalProperties: false
202219
}
@@ -1586,12 +1603,13 @@ function resolveContextReadAuthorization(binding, target, requested = {}) {
15861603
if (typeof binding !== 'object' || Array.isArray(binding)) {
15871604
throw contextBindingError('CONTEXT_BINDING_INVALID', 'contextBinding must be an object.')
15881605
}
1589-
const allowed = new Set(['schemaVersion', 'contextEpoch', 'planId', 'planContentId', 'activeRoot', 'project'])
1590-
const unknown = Object.keys(binding).filter(key => !allowed.has(key))
1606+
const unknown = Object.keys(binding).filter(key => !CONTEXT_READ_BINDING_ROUNDTRIP_FIELD_SET.has(key))
15911607
const requiredStrings = ['contextEpoch', 'planId', 'planContentId', 'activeRoot']
15921608
if (unknown.length || binding.schemaVersion !== 'ContextReadBindingV1' ||
15931609
requiredStrings.some(field => typeof binding[field] !== 'string' || !binding[field].trim()) ||
1594-
typeof binding.project !== 'string') {
1610+
typeof binding.project !== 'string' ||
1611+
(binding.bindingStatus !== undefined && binding.bindingStatus !== 'verified') ||
1612+
(binding.verificationMode !== undefined && binding.verificationMode !== 'request-bound')) {
15951613
throw contextBindingError('CONTEXT_BINDING_INVALID', 'contextBinding does not match the published ContextReadBindingV1 request schema.')
15961614
}
15971615
if (comparableActiveRoot(binding.activeRoot) !== comparableActiveRoot(target.activeRoot) ||

‎scripts/lib/memory-index.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
const {
1313
currentActiveSessionIds,
1414
foldSummaryRows,
15+
isCurrentProjectableRow,
1516
rowsByCurrentState,
1617
summaryStateConflicts
1718
} = require('./memory-summary-state.js')
@@ -164,7 +165,9 @@ function buildSummaryPartitions(document, parsed) {
164165
latestRows: rows.slice(-STATUS_WINDOW),
165166
activeSessionIds: currentActiveSessionIds(rows).slice(0, STATUS_LIST_SENTINEL),
166167
conflicts: summaryStateConflicts(rows).slice(0, STATUS_LIST_SENTINEL),
167-
nonCanonicalActiveCount: foldSummaryRows(rows).filter(row => row.state === 'active' && !row.sessionIdCanonical).length,
168+
nonCanonicalActiveCount: foldSummaryRows(rows).filter(row =>
169+
row.state === 'active' && !isCurrentProjectableRow(row)
170+
).length,
168171
warnings
169172
}
170173
const months = new Map()

‎scripts/lib/memory-summary-state.js‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
11
'use strict'
22

3+
let SUMMARY_TYPE_CANON = null
4+
try {
5+
SUMMARY_TYPE_CANON = require('./summary-type-canon.js')
6+
} catch {
7+
SUMMARY_TYPE_CANON = null
8+
}
9+
310
function summarySessionKey(row) {
411
if (!row || !row.day || !row.sessionId || row.sessionIdCanonical !== true) return null
512
return `${row.day}#${row.sessionId}`
613
}
714

15+
function isCanonicalSummaryType(row) {
16+
if (!SUMMARY_TYPE_CANON?.validateSummaryType) return true
17+
return SUMMARY_TYPE_CANON.validateSummaryType(row?.type).ok === true
18+
}
19+
20+
function isCurrentProjectableRow(row) {
21+
return Boolean(summarySessionKey(row)) && isCanonicalSummaryType(row)
22+
}
23+
824
function foldSummaryRows(rows = []) {
925
const current = new Map()
1026
for (const row of Array.isArray(rows) ? rows : []) {
@@ -18,7 +34,7 @@ function foldSummaryRows(rows = []) {
1834

1935
function rowsByCurrentState(rows = [], state = 'all') {
2036
if (state === 'all') return Array.isArray(rows) ? rows : []
21-
const current = foldSummaryRows(rows)
37+
const current = foldSummaryRows((Array.isArray(rows) ? rows : []).filter(isCurrentProjectableRow))
2238
if (state === 'unresolved') return current.filter(row => row.state === 'active' || row.state === 'blocked')
2339
return current.filter(row => row.state === state)
2440
}
@@ -48,16 +64,17 @@ function summaryStateConflicts(rows = []) {
4864
}
4965

5066
function currentActiveSessionIds(rows = []) {
51-
return foldSummaryRows(rows)
67+
return foldSummaryRows((Array.isArray(rows) ? rows : []).filter(isCurrentProjectableRow))
5268
.slice()
5369
.reverse()
54-
.filter(row => row.state === 'active' && row.sessionIdCanonical === true)
70+
.filter(row => row.state === 'active')
5571
.map(row => summarySessionKey(row))
5672
}
5773

5874
module.exports = {
5975
currentActiveSessionIds,
6076
foldSummaryRows,
77+
isCurrentProjectableRow,
6178
rowsByCurrentState,
6279
summarySessionKey,
6380
summaryStateConflicts

‎scripts/test-mcp-servers.js‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ function runServer(script, requests, cwd = ROOT, env = {}, followToolPages = tru
158158
cwd: ROOT,
159159
input,
160160
encoding: 'utf8',
161-
env: mergedEnv
161+
env: mergedEnv,
162+
maxBuffer: 64 * 1024 * 1024
162163
})
163164

164165
if (result.status !== 0) {
@@ -4701,6 +4702,11 @@ function testContextReadBindingContract() {
47014702
activeRoot: plan.identity.activeRoot,
47024703
project: plan.identity.project
47034704
}
4705+
const roundTripBinding = {
4706+
...binding,
4707+
bindingStatus: 'verified',
4708+
verificationMode: 'request-bound'
4709+
}
47044710

47054711
const profileResponses = runServer('mcp/profile-server.js', [
47064712
rpcRequest(2, 'tools/list'),
@@ -4723,6 +4729,17 @@ function testContextReadBindingContract() {
47234729
rpcRequest(7, 'tools/call', {
47244730
name: 'skill_route',
47254731
arguments: { op: 'catalog' }
4732+
}),
4733+
rpcRequest(8, 'tools/call', {
4734+
name: 'profile_load',
4735+
arguments: { files: ['01-项目信息.md'], contextBinding: roundTripBinding }
4736+
}),
4737+
rpcRequest(9, 'tools/call', {
4738+
name: 'profile_load',
4739+
arguments: {
4740+
files: ['01-项目信息.md'],
4741+
contextBinding: { ...binding, bindingStatus: 'pending' }
4742+
}
47264743
})
47274744
], TEMP_ROOT)
47284745
const listedResult = resultById(profileResponses, 2)
@@ -4823,6 +4840,10 @@ function testContextReadBindingContract() {
48234840
)
48244841
assert.strictEqual(resultById(profileResponses, 6).isError, true)
48254842
assert.strictEqual(unboundProfile.errorCode, 'CONTEXT_BINDING_REQUIRED')
4843+
assert.strictEqual(resultById(profileResponses, 8).isError, undefined)
4844+
const invalidProfileRoundTrip = toolJson(resultById(profileResponses, 9))
4845+
assert.strictEqual(resultById(profileResponses, 9).isError, true)
4846+
assert.strictEqual(invalidProfileRoundTrip.errorCode, 'CONTEXT_BINDING_INVALID')
48264847

48274848
const skillRouteTracePath = path.join(TEMP_ROOT, 'skill-route-call-trace.ndjson')
48284849
runServer('mcp/profile-server.js', [
@@ -4861,6 +4882,14 @@ function testContextReadBindingContract() {
48614882
rpcRequest(11, 'tools/call', {
48624883
name: 'memory_status',
48634884
arguments: { contextBinding: { ...binding, unsupported: true } }
4885+
}),
4886+
rpcRequest(12, 'tools/call', {
4887+
name: 'memory_status',
4888+
arguments: { contextBinding: roundTripBinding }
4889+
}),
4890+
rpcRequest(13, 'tools/call', {
4891+
name: 'memory_status',
4892+
arguments: { contextBinding: { ...binding, verificationMode: 'legacy' } }
48644893
})
48654894
], TEMP_ROOT)
48664895
assert.ok(resultById(memoryResponses, 7).tools
@@ -4880,6 +4909,12 @@ function testContextReadBindingContract() {
48804909
const invalidMemory = toolJson(resultById(memoryResponses, 11))
48814910
assert.strictEqual(resultById(memoryResponses, 11).isError, true)
48824911
assert.strictEqual(invalidMemory.errorCode, 'CONTEXT_BINDING_INVALID')
4912+
const roundTripMemory = toolJson(resultById(memoryResponses, 12))
4913+
assert.strictEqual(roundTripMemory.contextBinding.bindingStatus, 'verified')
4914+
assert.strictEqual(roundTripMemory.contextBinding.verificationMode, 'request-bound')
4915+
const invalidMemoryRoundTrip = toolJson(resultById(memoryResponses, 13))
4916+
assert.strictEqual(resultById(memoryResponses, 13).isError, true)
4917+
assert.strictEqual(invalidMemoryRoundTrip.errorCode, 'CONTEXT_BINDING_INVALID')
48834918
}
48844919

48854920
function testContextReadAuthorizationNegativePaths() {

‎scripts/test-memory-index.js‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ const summaryRows = [
9191
row('2026-07-22', '02', 'active', 5),
9292
row('2026-07-23', '02', 'blocked', 6),
9393
row('2026-07-23', '03', 'active', 7),
94-
row('2026-07-23', '03', 'completed', 8)
94+
row('2026-07-23', '03', 'completed', 8),
95+
{ ...row('2026-07-24', '04', 'active', 9), type: '04' },
96+
{ ...row('2026-07-24', '2026-07-24', 'active', 10), sessionIdCanonical: false }
9597
]
9698
const summaryRefresh = refreshSummaryIndex({
9799
target,
@@ -106,6 +108,8 @@ assert.equal(status.status, 'fresh')
106108
assert.equal(status.latestRows.length, 2)
107109
assert.equal(status.activeSessionIds[0], '2026-07-22#02')
108110
assert.ok(!status.activeSessionIds.includes('2026-07-23#03'))
111+
assert.ok(!status.activeSessionIds.includes('2026-07-24#04'))
112+
assert.equal(status.nonCanonicalActiveCount, 2)
109113
assert.deepEqual(status.conflicts, [])
110114
assert.equal(status.warnings[0], 'fixture-warning')
111115
assert.deepEqual(
@@ -142,6 +146,18 @@ const currentActive = querySummaryIndex({
142146
limit: 10
143147
})
144148
assert.deepEqual(currentActive.rows.map(item => `${item.day}#${item.sessionId}`), ['2026-07-22#02'])
149+
assert.equal(currentActive.totalMatched, 1)
150+
151+
const allSummary = querySummaryIndex({
152+
target,
153+
sourcePath: summaryPath,
154+
status: 'all',
155+
limit: 20
156+
})
157+
assert.ok(
158+
allSummary.rows.some(item => item.type === '04'),
159+
'non-canonical historical rows remain readable in all-history queries'
160+
)
145161

146162
const since = querySummaryIndex({
147163
target,

‎website/README.md‎

Lines changed: 0 additions & 21 deletions
This file was deleted.

0 commit comments

Comments
 (0)