@y0shua1ee/dsh-auto-memory 0.3.3 → 0.4.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/README.md +26 -14
- package/lib/config.d.ts +47 -0
- package/lib/config.js +89 -97
- package/lib/guidance.d.ts +9 -0
- package/lib/guidance.js +5 -9
- package/lib/index.d.ts +13 -0
- package/lib/index.js +68 -89
- package/lib/inject.d.ts +36 -0
- package/lib/inject.js +80 -82
- package/lib/memory-frontmatter.d.ts +60 -0
- package/lib/memory-frontmatter.js +169 -102
- package/lib/paths.d.ts +38 -0
- package/lib/paths.js +117 -66
- package/lib/render.d.ts +24 -0
- package/lib/render.js +44 -61
- package/lib/rule-frontmatter.d.ts +15 -0
- package/lib/rule-frontmatter.js +141 -74
- package/lib/rule-glob.d.ts +21 -0
- package/lib/rule-glob.js +149 -124
- package/lib/rules-inject.d.ts +14 -0
- package/lib/rules-inject.js +119 -111
- package/lib/rules.d.ts +62 -0
- package/lib/rules.js +235 -227
- package/lib/store.d.ts +169 -0
- package/lib/store.js +766 -761
- package/lib/tool.d.ts +63 -0
- package/lib/tool.js +224 -224
- package/lib/types.d.ts +122 -0
- package/lib/types.js +1 -0
- package/package.json +14 -7
package/lib/inject.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "./config.js";
|
|
2
|
+
import type { MemoryStore } from "./store.js";
|
|
3
|
+
import type { Agent, EnterDecision, Logger, Message, PreStepDecision, PreStepHandler } from "./types.js";
|
|
4
|
+
/** Message source kind used to recognize injected memory snapshots. */
|
|
5
|
+
export declare const AUTO_MEMORY_SOURCE_KIND = "auto-memory";
|
|
6
|
+
/** The project fields the injection path needs. */
|
|
7
|
+
export interface InjectionProject {
|
|
8
|
+
key: string;
|
|
9
|
+
dirLabel: string;
|
|
10
|
+
store: MemoryStore;
|
|
11
|
+
cwd: string;
|
|
12
|
+
projectRoot: string;
|
|
13
|
+
ruleRoot: string;
|
|
14
|
+
}
|
|
15
|
+
export type ProjectFor = (agent: Agent, signal?: AbortSignal) => Promise<InjectionProject>;
|
|
16
|
+
/**
|
|
17
|
+
* Whether the index holds anything beyond its heading and HTML comments.
|
|
18
|
+
* A header-only index (for example after deleting the last memory) carries no
|
|
19
|
+
* context and is not worth injecting.
|
|
20
|
+
*/
|
|
21
|
+
export declare function hasIndexEntries(content: string): boolean;
|
|
22
|
+
export declare function isSubagent(agent: Agent): boolean;
|
|
23
|
+
export declare function enterWithMessage(agent: Agent, messages: Message[], decision: EnterDecision, message: Message, isManaged: (candidate: Message) => boolean): PreStepDecision;
|
|
24
|
+
/**
|
|
25
|
+
* Create the `agent/pre-step` listener that injects the project index.
|
|
26
|
+
*
|
|
27
|
+
* The index is re-read on every step. The rendered snapshot's digest is
|
|
28
|
+
* recorded in the message source, so the same snapshot is never injected
|
|
29
|
+
* twice; after compaction the old message leaves the surface and the next step
|
|
30
|
+
* injects the current index again.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createPreStepHandler({ projectFor, config, logger }: {
|
|
33
|
+
projectFor: ProjectFor;
|
|
34
|
+
config: ResolvedConfig;
|
|
35
|
+
logger?: Logger;
|
|
36
|
+
}): PreStepHandler;
|
package/lib/inject.js
CHANGED
|
@@ -1,66 +1,62 @@
|
|
|
1
1
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
2
|
import { renderMemoryContext } from "./render.js";
|
|
3
|
-
|
|
4
3
|
/** Message source kind used to recognize injected memory snapshots. */
|
|
5
4
|
export const AUTO_MEMORY_SOURCE_KIND = "auto-memory";
|
|
6
|
-
|
|
7
5
|
function isAutoMemoryMessage(message) {
|
|
8
|
-
|
|
6
|
+
return message?.source?.kind === AUTO_MEMORY_SOURCE_KIND;
|
|
9
7
|
}
|
|
10
|
-
|
|
11
8
|
/**
|
|
12
9
|
* Whether the index holds anything beyond its heading and HTML comments.
|
|
13
10
|
* A header-only index (for example after deleting the last memory) carries no
|
|
14
11
|
* context and is not worth injecting.
|
|
15
|
-
*
|
|
16
|
-
* @param {string} content - raw MEMORY.md text.
|
|
17
|
-
* @returns {boolean} true when at least one non-heading line exists.
|
|
18
12
|
*/
|
|
19
13
|
export function hasIndexEntries(content) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
14
|
+
return content
|
|
15
|
+
.replace(/<!--[\s\S]*?-->/gu, "")
|
|
16
|
+
.split("\n")
|
|
17
|
+
.some((line) => {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
return trimmed.length > 0 && !trimmed.startsWith("#");
|
|
26
20
|
});
|
|
27
21
|
}
|
|
28
|
-
|
|
29
22
|
export function isSubagent(agent) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
const header = agent.session.header;
|
|
24
|
+
if (header === undefined || header === null)
|
|
25
|
+
return false;
|
|
26
|
+
return header.origin === "subagent" || (header.delegationDepth ?? 0) > 0;
|
|
33
27
|
}
|
|
34
|
-
|
|
35
28
|
function hasDigest(agent, decision, digest) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
29
|
+
const matches = (message) => isAutoMemoryMessage(message) && message?.source?.digest === digest;
|
|
30
|
+
if (decision.messages.some(matches))
|
|
31
|
+
return true;
|
|
32
|
+
for (const seq of agent.session.surface.nodes) {
|
|
33
|
+
const event = agent.session.eventAt(seq);
|
|
34
|
+
if (event?.type === "user/message" && matches(event.data))
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return agent.inbox.nextStep.some(matches);
|
|
43
38
|
}
|
|
44
|
-
|
|
45
39
|
export function enterWithMessage(agent, messages, decision, message, isManaged) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
40
|
+
const pending = agent.inbox.nextStep.filter(isManaged);
|
|
41
|
+
if (decision.messages.length === 0) {
|
|
42
|
+
if (pending.length > 0) {
|
|
43
|
+
agent.inbox.replace(pending[0].id, message);
|
|
44
|
+
for (const extra of pending.slice(1))
|
|
45
|
+
agent.inbox.remove(extra.id);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
agent.inbox.prepend("next-step", message);
|
|
49
|
+
}
|
|
50
|
+
return decision;
|
|
53
51
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
};
|
|
52
|
+
for (const extra of pending)
|
|
53
|
+
agent.inbox.remove(extra.id);
|
|
54
|
+
const lastClaimedIndex = decision.messages.findLastIndex((candidate) => messages.includes(candidate));
|
|
55
|
+
return {
|
|
56
|
+
...decision,
|
|
57
|
+
messages: decision.messages.toSpliced(lastClaimedIndex + 1, 0, message)
|
|
58
|
+
};
|
|
62
59
|
}
|
|
63
|
-
|
|
64
60
|
/**
|
|
65
61
|
* Create the `agent/pre-step` listener that injects the project index.
|
|
66
62
|
*
|
|
@@ -68,47 +64,49 @@ export function enterWithMessage(agent, messages, decision, message, isManaged)
|
|
|
68
64
|
* recorded in the message source, so the same snapshot is never injected
|
|
69
65
|
* twice; after compaction the old message leaves the surface and the next step
|
|
70
66
|
* injects the current index again.
|
|
71
|
-
*
|
|
72
|
-
* @param {object} input - listener dependencies.
|
|
73
|
-
* @param {(agent: object, signal: AbortSignal) => Promise<object>} input.projectFor - resolve the session's project.
|
|
74
|
-
* @param {object} input.config - normalized plugin configuration.
|
|
75
|
-
* @param {object} [input.logger] - host logger.
|
|
76
|
-
* @returns {(payload: object, next: Function) => Promise<object>} pre-step listener.
|
|
77
67
|
*/
|
|
78
68
|
export function createPreStepHandler({ projectFor, config, logger }) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
69
|
+
return async ({ agent, messages, signal }, next) => {
|
|
70
|
+
const decision = await next();
|
|
71
|
+
if (decision.kind === "reject")
|
|
72
|
+
return decision;
|
|
73
|
+
if (!config.inject)
|
|
74
|
+
return decision;
|
|
75
|
+
if (!config.injectInSubagents && isSubagent(agent))
|
|
76
|
+
return decision;
|
|
77
|
+
try {
|
|
78
|
+
const project = await projectFor(agent, signal);
|
|
79
|
+
const index = await project.store.index();
|
|
80
|
+
if (!index.exists || !hasIndexEntries(index.content))
|
|
81
|
+
return decision;
|
|
82
|
+
const rendered = renderMemoryContext({
|
|
83
|
+
content: index.content,
|
|
84
|
+
memoryDir: project.dirLabel,
|
|
85
|
+
projectKey: project.key,
|
|
86
|
+
maxBytes: config.maxIndexBytes,
|
|
87
|
+
maxLines: config.maxIndexLines,
|
|
88
|
+
replacePrevious: true,
|
|
89
|
+
sourceTruncated: index.truncated
|
|
90
|
+
});
|
|
91
|
+
if (hasDigest(agent, decision, rendered.digest))
|
|
92
|
+
return decision;
|
|
93
|
+
const message = createUserMessage({
|
|
94
|
+
content: [{ type: "text", text: rendered.text }],
|
|
95
|
+
source: {
|
|
96
|
+
kind: AUTO_MEMORY_SOURCE_KIND,
|
|
97
|
+
form: "memory",
|
|
98
|
+
projectKey: project.key,
|
|
99
|
+
digest: rendered.digest,
|
|
100
|
+
baseline: true
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return enterWithMessage(agent, messages, decision, message, isAutoMemoryMessage);
|
|
105
104
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
};
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (signal?.aborted)
|
|
107
|
+
return decision;
|
|
108
|
+
logger?.warn?.("auto-memory: could not load project memory for this step: %o", error);
|
|
109
|
+
return decision;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
114
112
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the frontmatter of one memory file, in the shape Claude Code uses:
|
|
3
|
+
*
|
|
4
|
+
* ```markdown
|
|
5
|
+
* ---
|
|
6
|
+
* name: prefer-pnpm
|
|
7
|
+
* description: The user wants pnpm, never npm, in this repo
|
|
8
|
+
* metadata:
|
|
9
|
+
* type: feedback
|
|
10
|
+
* ---
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Only flat `key: value` scalars and the one-level `metadata:` block are read;
|
|
14
|
+
* a top-level `type` (and `modified`) is accepted as a fallback. Anything
|
|
15
|
+
* unparsable yields no fields, so a malformed file still loads as plain text.
|
|
16
|
+
*/
|
|
17
|
+
/** Memory types, mirroring Claude Code's memory taxonomy. */
|
|
18
|
+
export declare const MEMORY_TYPES: readonly ["user", "feedback", "project", "reference"];
|
|
19
|
+
export type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
20
|
+
/** Memory names share DSH's skill-name shape so `[[name]]` maps to `name.md`. */
|
|
21
|
+
export declare const MEMORY_NAME: RegExp;
|
|
22
|
+
/** One parsed memory file. */
|
|
23
|
+
export interface ParsedMemory {
|
|
24
|
+
frontmatter: boolean;
|
|
25
|
+
name?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
type?: string;
|
|
28
|
+
modified?: string;
|
|
29
|
+
body: string;
|
|
30
|
+
}
|
|
31
|
+
/** One stable frontmatter problem code plus a readable message. */
|
|
32
|
+
export interface MemoryIssue {
|
|
33
|
+
code: string;
|
|
34
|
+
message: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read a memory file's frontmatter fields and body.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseMemory(content: string): ParsedMemory;
|
|
40
|
+
/**
|
|
41
|
+
* Describe each frontmatter problem as a stable code plus a readable message.
|
|
42
|
+
*
|
|
43
|
+
* Codes stay stable when only the offending value changes, so callers can tell
|
|
44
|
+
* "the same problem still exists" from "this edit introduced a new problem".
|
|
45
|
+
*/
|
|
46
|
+
export declare function memoryFrontmatterIssues(parsed: ParsedMemory, fileStem?: string, filePath?: string): MemoryIssue[];
|
|
47
|
+
/**
|
|
48
|
+
* List what a topic file's frontmatter is missing, for a non-blocking hint.
|
|
49
|
+
*/
|
|
50
|
+
export declare function memoryFrontmatterProblems(parsed: ParsedMemory, fileStem?: string, filePath?: string): string[];
|
|
51
|
+
/**
|
|
52
|
+
* Record the write time in a `modified` frontmatter field.
|
|
53
|
+
*
|
|
54
|
+
* Claude Code records an ISO 8601 timestamp when it writes a memory file that
|
|
55
|
+
* begins with YAML frontmatter. A file without frontmatter is left untouched;
|
|
56
|
+
* an existing top-level or `metadata.modified` value is replaced in place, and
|
|
57
|
+
* a `metadata.modified` value is added only when a `metadata:` block exists.
|
|
58
|
+
* Line endings and every other line are preserved.
|
|
59
|
+
*/
|
|
60
|
+
export declare function touchModified(content: string, now?: Date): string;
|
|
@@ -11,131 +11,198 @@
|
|
|
11
11
|
* ```
|
|
12
12
|
*
|
|
13
13
|
* Only flat `key: value` scalars and the one-level `metadata:` block are read;
|
|
14
|
-
* a top-level `type` is accepted as a fallback. Anything
|
|
15
|
-
* fields, so a malformed file still loads as plain text.
|
|
14
|
+
* a top-level `type` (and `modified`) is accepted as a fallback. Anything
|
|
15
|
+
* unparsable yields no fields, so a malformed file still loads as plain text.
|
|
16
16
|
*/
|
|
17
|
-
|
|
18
17
|
const DELIMITER = "---";
|
|
19
|
-
|
|
20
18
|
/** Memory types, mirroring Claude Code's memory taxonomy. */
|
|
21
19
|
export const MEMORY_TYPES = ["user", "feedback", "project", "reference"];
|
|
22
|
-
|
|
23
20
|
/** Memory names share DSH's skill-name shape so `[[name]]` maps to `name.md`. */
|
|
24
21
|
export const MEMORY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
25
|
-
|
|
26
22
|
function scalar(value) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return trimmed
|
|
34
|
-
}
|
|
35
|
-
return trimmed;
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
if (trimmed.length >= 2 &&
|
|
25
|
+
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
26
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
|
27
|
+
return trimmed.slice(1, -1).trim();
|
|
28
|
+
}
|
|
29
|
+
return trimmed;
|
|
36
30
|
}
|
|
37
|
-
|
|
38
31
|
/**
|
|
39
32
|
* Read a memory file's frontmatter fields and body.
|
|
40
|
-
*
|
|
41
|
-
* @param {string} content - complete memory file text.
|
|
42
|
-
* @returns {{frontmatter: boolean, name?: string, description?: string, type?: string, body: string}}
|
|
43
33
|
*/
|
|
44
34
|
export function parseMemory(content) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
35
|
+
const source = content.startsWith("\uFEFF") ? content.slice(1) : content;
|
|
36
|
+
const lines = source.split(/\r?\n/u);
|
|
37
|
+
if (lines[0]?.trim() !== DELIMITER)
|
|
38
|
+
return { frontmatter: false, body: source };
|
|
39
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === DELIMITER);
|
|
40
|
+
if (end === -1)
|
|
41
|
+
return { frontmatter: false, body: source };
|
|
42
|
+
const top = {};
|
|
43
|
+
const metadata = {};
|
|
44
|
+
let inMetadata = false;
|
|
45
|
+
for (const line of lines.slice(1, end)) {
|
|
46
|
+
if (line.trim().length === 0 || line.trim().startsWith("#"))
|
|
47
|
+
continue;
|
|
48
|
+
const match = /^(\s*)([A-Za-z_][\w-]*)\s*:(.*)$/u.exec(line);
|
|
49
|
+
if (match === null)
|
|
50
|
+
continue;
|
|
51
|
+
const [, indent, key, rest] = match;
|
|
52
|
+
if (indent.length === 0) {
|
|
53
|
+
inMetadata = key === "metadata" && rest.trim().length === 0;
|
|
54
|
+
if (!inMetadata)
|
|
55
|
+
top[key] = scalar(rest);
|
|
56
|
+
}
|
|
57
|
+
else if (inMetadata) {
|
|
58
|
+
metadata[key] = scalar(rest);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const result = {
|
|
62
|
+
frontmatter: true,
|
|
63
|
+
body: lines.slice(end + 1).join("\n").replace(/^\n+/u, "")
|
|
64
|
+
};
|
|
65
|
+
const name = top.name;
|
|
66
|
+
const description = top.description;
|
|
67
|
+
const type = metadata.type ?? top.type;
|
|
68
|
+
const modified = metadata.modified ?? top.modified;
|
|
69
|
+
if (name !== undefined && name.length > 0)
|
|
70
|
+
result.name = name;
|
|
71
|
+
if (description !== undefined && description.length > 0)
|
|
72
|
+
result.description = description;
|
|
73
|
+
if (type !== undefined && type.length > 0)
|
|
74
|
+
result.type = type;
|
|
75
|
+
if (modified !== undefined && modified.length > 0)
|
|
76
|
+
result.modified = modified;
|
|
77
|
+
return result;
|
|
76
78
|
}
|
|
77
|
-
|
|
78
79
|
/**
|
|
79
80
|
* Describe each frontmatter problem as a stable code plus a readable message.
|
|
80
81
|
*
|
|
81
82
|
* Codes stay stable when only the offending value changes, so callers can tell
|
|
82
83
|
* "the same problem still exists" from "this edit introduced a new problem".
|
|
83
|
-
*
|
|
84
|
-
* @param {ReturnType<typeof parseMemory>} parsed - parsed memory file.
|
|
85
|
-
* @param {string} [fileStem] - file name without `.md`; `[[name]]` resolves to it.
|
|
86
|
-
* @param {string} [filePath] - project-relative path, used to detect subdirectories.
|
|
87
|
-
* @returns {Array<{code: string, message: string}>} problems; empty when well-formed.
|
|
88
84
|
*/
|
|
89
85
|
export function memoryFrontmatterIssues(parsed, fileStem, filePath) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
86
|
+
if (!parsed.frontmatter) {
|
|
87
|
+
return [{ code: "frontmatter-missing", message: "no frontmatter (name, description, metadata.type)" }];
|
|
88
|
+
}
|
|
89
|
+
const issues = [];
|
|
90
|
+
if (parsed.name === undefined) {
|
|
91
|
+
issues.push({ code: "name-missing", message: "missing name" });
|
|
92
|
+
}
|
|
93
|
+
else if (!MEMORY_NAME.test(parsed.name)) {
|
|
94
|
+
issues.push({
|
|
95
|
+
code: "name-invalid",
|
|
96
|
+
message: `name ${JSON.stringify(parsed.name)} is not kebab-case`
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
else if (fileStem !== undefined && parsed.name !== fileStem) {
|
|
100
|
+
const file = filePath ?? `${fileStem}.md`;
|
|
101
|
+
issues.push({
|
|
102
|
+
code: "name-unmatched",
|
|
103
|
+
message: `name ${JSON.stringify(parsed.name)} does not match the file name ${file}, so [[${parsed.name}]] will not resolve`
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
else if (filePath !== undefined && filePath.includes("/")) {
|
|
107
|
+
issues.push({
|
|
108
|
+
code: "name-nested",
|
|
109
|
+
message: `name ${JSON.stringify(parsed.name)} matches the file name, but the file is in a subdirectory; ` +
|
|
110
|
+
`[[${parsed.name}]] resolves to /memories/${parsed.name}.md, not this file`
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (parsed.description === undefined) {
|
|
114
|
+
issues.push({ code: "description-missing", message: "missing description" });
|
|
115
|
+
}
|
|
116
|
+
if (parsed.type === undefined) {
|
|
117
|
+
issues.push({ code: "type-missing", message: "missing metadata.type" });
|
|
118
|
+
}
|
|
119
|
+
else if (!MEMORY_TYPES.includes(parsed.type)) {
|
|
120
|
+
issues.push({
|
|
121
|
+
code: "type-invalid",
|
|
122
|
+
message: `metadata.type ${JSON.stringify(parsed.type)} is not one of ${MEMORY_TYPES.join(", ")}`
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return issues;
|
|
129
126
|
}
|
|
130
|
-
|
|
131
127
|
/**
|
|
132
128
|
* List what a topic file's frontmatter is missing, for a non-blocking hint.
|
|
133
|
-
*
|
|
134
|
-
* @param {ReturnType<typeof parseMemory>} parsed - parsed memory file.
|
|
135
|
-
* @param {string} [fileStem] - file name without `.md`; `[[name]]` resolves to it.
|
|
136
|
-
* @param {string} [filePath] - project-relative path, used to detect subdirectories.
|
|
137
|
-
* @returns {string[]} human-readable problems; empty when the file is well-formed.
|
|
138
129
|
*/
|
|
139
130
|
export function memoryFrontmatterProblems(parsed, fileStem, filePath) {
|
|
140
|
-
|
|
131
|
+
return memoryFrontmatterIssues(parsed, fileStem, filePath).map((issue) => issue.message);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Record the write time in a `modified` frontmatter field.
|
|
135
|
+
*
|
|
136
|
+
* Claude Code records an ISO 8601 timestamp when it writes a memory file that
|
|
137
|
+
* begins with YAML frontmatter. A file without frontmatter is left untouched;
|
|
138
|
+
* an existing top-level or `metadata.modified` value is replaced in place, and
|
|
139
|
+
* a `metadata.modified` value is added only when a `metadata:` block exists.
|
|
140
|
+
* Line endings and every other line are preserved.
|
|
141
|
+
*/
|
|
142
|
+
export function touchModified(content, now = new Date()) {
|
|
143
|
+
const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
144
|
+
const source = bom.length > 0 ? content.slice(1) : content;
|
|
145
|
+
const eol = source.includes("\r\n") ? "\r\n" : "\n";
|
|
146
|
+
const lines = source.split(/\r?\n/u);
|
|
147
|
+
if (lines[0]?.trim() !== DELIMITER)
|
|
148
|
+
return content;
|
|
149
|
+
let end = -1;
|
|
150
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
151
|
+
if (lines[index].trim() === DELIMITER) {
|
|
152
|
+
end = index;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (end === -1)
|
|
157
|
+
return content;
|
|
158
|
+
const timestamp = now.toISOString();
|
|
159
|
+
const frontmatter = lines.slice(1, end);
|
|
160
|
+
let topModified = -1;
|
|
161
|
+
let metadataStart = -1;
|
|
162
|
+
let metadataEnd = -1;
|
|
163
|
+
for (let index = 0; index < frontmatter.length; index += 1) {
|
|
164
|
+
const line = frontmatter[index];
|
|
165
|
+
if (topModified === -1 && /^modified\s*:/u.test(line))
|
|
166
|
+
topModified = index;
|
|
167
|
+
if (metadataStart === -1 && /^metadata\s*:\s*$/u.test(line))
|
|
168
|
+
metadataStart = index;
|
|
169
|
+
}
|
|
170
|
+
if (metadataStart !== -1) {
|
|
171
|
+
metadataEnd = metadataStart + 1;
|
|
172
|
+
while (metadataEnd < frontmatter.length) {
|
|
173
|
+
const line = frontmatter[metadataEnd];
|
|
174
|
+
if (line.trim().length === 0)
|
|
175
|
+
break;
|
|
176
|
+
if (!/^\s+/u.test(line))
|
|
177
|
+
break;
|
|
178
|
+
metadataEnd += 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
let nestedModified = -1;
|
|
182
|
+
if (metadataStart !== -1) {
|
|
183
|
+
for (let index = metadataStart + 1; index < metadataEnd; index += 1) {
|
|
184
|
+
if (/^\s+modified\s*:/u.test(frontmatter[index])) {
|
|
185
|
+
nestedModified = index;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const replaceValue = (line) => {
|
|
191
|
+
const prefix = /^(\s*modified\s*:).*$/u.exec(line);
|
|
192
|
+
return prefix === null ? line : `${prefix[1]} ${timestamp}`;
|
|
193
|
+
};
|
|
194
|
+
if (topModified !== -1) {
|
|
195
|
+
frontmatter[topModified] = replaceValue(frontmatter[topModified]);
|
|
196
|
+
}
|
|
197
|
+
if (nestedModified !== -1) {
|
|
198
|
+
frontmatter[nestedModified] = replaceValue(frontmatter[nestedModified]);
|
|
199
|
+
}
|
|
200
|
+
if (nestedModified === -1 && metadataStart !== -1) {
|
|
201
|
+
frontmatter.splice(metadataStart + 1, 0, ` modified: ${timestamp}`);
|
|
202
|
+
}
|
|
203
|
+
if (topModified === -1 && metadataStart === -1) {
|
|
204
|
+
frontmatter.push(`modified: ${timestamp}`);
|
|
205
|
+
}
|
|
206
|
+
const next = [lines[0], ...frontmatter, ...lines.slice(end)].join(eol);
|
|
207
|
+
return `${bom}${next}`;
|
|
141
208
|
}
|
package/lib/paths.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encode one project directory the same way DSH names its session project
|
|
3
|
+
* directories: separator and drive runs collapse to a single `-`, unsafe code
|
|
4
|
+
* units use `~XXXX`, and the result is wrapped in `--…--`.
|
|
5
|
+
*
|
|
6
|
+
* Keeping this identical to `@deepseek-ai/dsh-session-persistence-jsonl` means
|
|
7
|
+
* memory folders sit next to the session folders they belong to when the user
|
|
8
|
+
* browses `~/.dsh`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function projectKey(cwd: string): string;
|
|
11
|
+
/** The memory key root and the directory project rules are anchored to. */
|
|
12
|
+
export interface ProjectLocation {
|
|
13
|
+
/** Shared memory key: the main checkout for linked worktrees. */
|
|
14
|
+
projectRoot: string;
|
|
15
|
+
/** Directory that owns `.claude/rules` / `.dsh/rules` for this session. */
|
|
16
|
+
ruleRoot: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Walk up from `cwd` until a directory containing one of `markers` is found.
|
|
20
|
+
*
|
|
21
|
+
* Memory is keyed by `projectRoot` (a linked worktree resolves to its main
|
|
22
|
+
* checkout), while project rules and their `paths` base stay anchored to the
|
|
23
|
+
* checkout the session actually works in (`ruleRoot`). When no marker exists
|
|
24
|
+
* the working directory is both.
|
|
25
|
+
*/
|
|
26
|
+
export declare function findProjectLocation(cwd: string, markers: string[], signal?: AbortSignal): Promise<ProjectLocation>;
|
|
27
|
+
/**
|
|
28
|
+
* Return only the memory project root (the main checkout for linked worktrees).
|
|
29
|
+
*/
|
|
30
|
+
export declare function findProjectRoot(cwd: string, markers: string[], signal?: AbortSignal): Promise<string>;
|
|
31
|
+
/**
|
|
32
|
+
* Abbreviate a path below the OS home for model-facing prompts.
|
|
33
|
+
*/
|
|
34
|
+
export declare function displayPath(path: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Compute the memory directory for one project.
|
|
37
|
+
*/
|
|
38
|
+
export declare function memoryDirectory(memoryRoot: string, key: string): string;
|