pi-sessions 0.5.1 → 0.7.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.
Files changed (57) hide show
  1. package/README.md +22 -1
  2. package/extensions/session-ask/agent.ts +400 -0
  3. package/extensions/session-ask/navigate.ts +880 -0
  4. package/extensions/session-ask.ts +82 -134
  5. package/extensions/session-auto-title/command.ts +1 -1
  6. package/extensions/session-auto-title/controller.ts +3 -3
  7. package/extensions/session-auto-title/generate.ts +2 -2
  8. package/extensions/session-auto-title/model.ts +4 -12
  9. package/extensions/session-auto-title/retitle.ts +5 -5
  10. package/extensions/session-auto-title/state.ts +1 -1
  11. package/extensions/session-auto-title/wizard.ts +4 -4
  12. package/extensions/session-auto-title.ts +8 -8
  13. package/extensions/session-handoff/extract.ts +18 -5
  14. package/extensions/session-handoff/metadata.ts +4 -1
  15. package/extensions/session-handoff/picker.ts +52 -6
  16. package/extensions/session-handoff/query.ts +78 -62
  17. package/extensions/session-handoff/refs.ts +14 -20
  18. package/extensions/session-handoff/spawn.ts +1 -1
  19. package/extensions/session-handoff.ts +54 -35
  20. package/extensions/session-hooks.ts +3 -3
  21. package/extensions/session-index.ts +4 -4
  22. package/extensions/session-messaging/broker/process.ts +302 -0
  23. package/extensions/session-messaging/broker/spawn.ts +164 -0
  24. package/extensions/session-messaging/pi/incoming-runtime.ts +114 -0
  25. package/extensions/session-messaging/pi/message-contracts.ts +36 -0
  26. package/extensions/session-messaging/pi/message-view.ts +131 -0
  27. package/extensions/session-messaging/pi/renderer.ts +16 -0
  28. package/extensions/session-messaging/pi/service.ts +370 -0
  29. package/extensions/session-messaging/pi/tools.ts +92 -0
  30. package/extensions/session-messaging.ts +30 -0
  31. package/extensions/session-search/extract.ts +90 -440
  32. package/extensions/session-search/hooks.ts +20 -28
  33. package/extensions/session-search/normalize.ts +1 -1
  34. package/extensions/session-search/reindex.ts +3 -2
  35. package/extensions/session-search.ts +161 -132
  36. package/extensions/shared/model.ts +18 -0
  37. package/extensions/shared/session-broker/active.ts +26 -0
  38. package/extensions/shared/session-broker/client.ts +253 -0
  39. package/extensions/shared/session-broker/framing.ts +72 -0
  40. package/extensions/shared/session-broker/protocol.ts +95 -0
  41. package/extensions/shared/session-broker/socket-path.ts +30 -0
  42. package/extensions/shared/session-index/access.ts +128 -0
  43. package/extensions/shared/session-index/common.ts +46 -51
  44. package/extensions/shared/session-index/index.ts +6 -5
  45. package/extensions/shared/session-index/lineage.ts +19 -2
  46. package/extensions/shared/session-index/query/ast.ts +40 -0
  47. package/extensions/shared/session-index/query/compiler.ts +146 -0
  48. package/extensions/shared/session-index/query/lexer.ts +140 -0
  49. package/extensions/shared/session-index/query/parser.ts +178 -0
  50. package/extensions/shared/session-index/schema.ts +25 -9
  51. package/extensions/shared/session-index/scoring.ts +80 -0
  52. package/extensions/shared/session-index/search.ts +555 -281
  53. package/extensions/shared/session-index/sqlite.ts +16 -9
  54. package/extensions/shared/session-index/store.ts +14 -23
  55. package/extensions/shared/settings.ts +62 -5
  56. package/extensions/shared/text.ts +50 -0
  57. package/package.json +4 -3
@@ -1,12 +1,13 @@
1
1
  import { Type } from "typebox";
2
- import type { FileTouchOp, FileTouchSource, PathScope } from "../../session-search/normalize.js";
3
- import { safeParseTypeBoxJson } from "../typebox.js";
4
- import type { SqliteDatabase } from "./sqlite.js";
2
+ import type { FileTouchOp, FileTouchSource, PathScope } from "../../session-search/normalize.ts";
3
+ import { safeParseTypeBoxJson } from "../typebox.ts";
4
+ import type { SqliteDatabase } from "./sqlite.ts";
5
5
 
6
- export const INDEX_SCHEMA_VERSION = 8;
6
+ export const INDEX_SCHEMA_VERSION = 12;
7
7
 
8
8
  export type SessionOrigin = "handoff" | "fork" | "unknown_child";
9
9
  export type SessionLineageRelation =
10
+ | "self"
10
11
  | "parent"
11
12
  | "ancestor"
12
13
  | "child"
@@ -14,6 +15,8 @@ export type SessionLineageRelation =
14
15
  | "sibling"
15
16
  | "ancestor_sibling";
16
17
 
18
+ export type SearchSort = "relevance" | "modified_desc" | "modified_asc";
19
+
17
20
  export interface SessionRow {
18
21
  sessionId: string;
19
22
  sessionPath: string;
@@ -58,7 +61,7 @@ export interface SessionRelatedSessionRow extends SessionLineageRow {
58
61
  export interface SessionTextChunkRow {
59
62
  id?: number | undefined;
60
63
  sessionId: string;
61
- entryId?: string | undefined;
64
+ entryId: string;
62
65
  entryType: string;
63
66
  role?: string | undefined;
64
67
  ts: string;
@@ -89,9 +92,34 @@ export interface SearchSessionsParams {
89
92
  after?: string | undefined;
90
93
  before?: string | undefined;
91
94
  touched?: string[] | undefined;
95
+ changed?: string[] | undefined;
96
+ sort?: SearchSort | undefined;
92
97
  limit?: number | undefined;
93
98
  excludeSessionIds?: string[] | undefined;
94
- }
99
+ includeSessionIds?: string[] | undefined;
100
+ relativeToSessionId?: string | undefined;
101
+ }
102
+
103
+ export type SessionSearchEvidence =
104
+ | {
105
+ kind: "text";
106
+ sourceKind: string;
107
+ snippet: string;
108
+ score: number;
109
+ entryId: string;
110
+ }
111
+ | {
112
+ kind: "file_touch";
113
+ op: "read" | "changed";
114
+ query: string;
115
+ path: string;
116
+ entryId?: string | undefined;
117
+ }
118
+ | {
119
+ kind: "session_id";
120
+ match: "exact" | "prefix" | "substring";
121
+ score: number;
122
+ };
95
123
 
96
124
  export interface SearchSessionResult {
97
125
  sessionId: string;
@@ -108,8 +136,9 @@ export interface SearchSessionResult {
108
136
  sessionOrigin?: SessionOrigin | undefined;
109
137
  handoffGoal?: string | undefined;
110
138
  handoffNextTask?: string | undefined;
139
+ relation?: SessionLineageRelation | undefined;
111
140
  snippet: string;
112
- matchedFiles: string[];
141
+ evidence: SessionSearchEvidence[];
113
142
  score: number;
114
143
  hitCount: number;
115
144
  }
@@ -131,6 +160,7 @@ export const SESSION_ORIGIN_SCHEMA = Type.Union([
131
160
  Type.Literal("unknown_child"),
132
161
  ]);
133
162
  export const SESSION_LINEAGE_RELATION_SCHEMA = Type.Union([
163
+ Type.Literal("self"),
134
164
  Type.Literal("parent"),
135
165
  Type.Literal("ancestor"),
136
166
  Type.Literal("child"),
@@ -150,7 +180,7 @@ export function parseRepoRoots(value: string): string[] {
150
180
  }
151
181
 
152
182
  export function escapeLikePrefix(value: string): string {
153
- return value.replace(/[%_]/g, "\\$&");
183
+ return value.replace(/[%_\\]/g, "\\$&");
154
184
  }
155
185
 
156
186
  export function normalizeTimeFilter(value?: string): string | undefined {
@@ -160,7 +190,7 @@ export function normalizeTimeFilter(value?: string): string | undefined {
160
190
 
161
191
  const date = new Date(value);
162
192
  if (Number.isNaN(date.getTime())) {
163
- return undefined;
193
+ throw new Error(`Invalid time filter value: ${value}`);
164
194
  }
165
195
 
166
196
  return date.toISOString();
@@ -174,48 +204,6 @@ export function sanitizeFilterValues(values?: string[]): string[] {
174
204
  return values.map((value) => value.trim()).filter((value) => value.length > 0);
175
205
  }
176
206
 
177
- export function boostIndependentHits(hitCount: number): number {
178
- return hitCount > 1 ? (hitCount - 1) * 0.75 : 0;
179
- }
180
-
181
- export function buildFtsQuery(query: string): string | undefined {
182
- const trimmed = query.trim();
183
- if (!trimmed) {
184
- return undefined;
185
- }
186
-
187
- if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
188
- const exact = sanitizeFtsToken(trimmed.slice(1, -1));
189
- return exact ? quoteFtsToken(exact) : undefined;
190
- }
191
-
192
- const tokens = tokenizeSearchTerms(trimmed);
193
- if (tokens.length === 0) {
194
- return undefined;
195
- }
196
-
197
- return tokens.map(quoteFtsPrefixToken).join(" AND ");
198
- }
199
-
200
- export function tokenizeSearchTerms(query: string): string[] {
201
- return query
202
- .split(/\s+/)
203
- .flatMap((token) => sanitizeFtsToken(token).split(/\s+/))
204
- .filter((token) => token.length > 0);
205
- }
206
-
207
- export function sanitizeFtsToken(token: string): string {
208
- return token.replace(/[^A-Za-z0-9_]+/g, " ").trim();
209
- }
210
-
211
- export function quoteFtsToken(token: string): string {
212
- return `"${token.replace(/"/g, '""')}"`;
213
- }
214
-
215
- export function quoteFtsPrefixToken(token: string): string {
216
- return `${quoteFtsToken(token)}*`;
217
- }
218
-
219
207
  export function compactSearchValue(value: string): string {
220
208
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
221
209
  }
@@ -223,3 +211,10 @@ export function compactSearchValue(value: string): string {
223
211
  export function compactSessionId(value: string): string {
224
212
  return value.toLowerCase().replace(/-/g, "");
225
213
  }
214
+
215
+ export function tokenizeSearchText(value: string): string[] {
216
+ return value
217
+ .split(/[^A-Za-z0-9_]+/g)
218
+ .map((token) => token.trim())
219
+ .filter((token) => token.length > 0);
220
+ }
@@ -1,5 +1,6 @@
1
- export * from "./common.js";
2
- export * from "./lineage.js";
3
- export * from "./schema.js";
4
- export * from "./search.js";
5
- export * from "./store.js";
1
+ export * from "./access.ts";
2
+ export * from "./common.ts";
3
+ export * from "./lineage.ts";
4
+ export * from "./schema.ts";
5
+ export * from "./search.ts";
6
+ export * from "./store.ts";
@@ -1,5 +1,5 @@
1
1
  import { type Static, Type } from "typebox";
2
- import { parseTypeBoxRows, parseTypeBoxValue } from "../typebox.js";
2
+ import { parseTypeBoxRows, parseTypeBoxValue } from "../typebox.ts";
3
3
  import {
4
4
  NULLABLE_STRING_SCHEMA,
5
5
  parseRepoRoots,
@@ -9,7 +9,7 @@ import {
9
9
  type SessionLineageRelation,
10
10
  type SessionLineageRow,
11
11
  type SessionRelatedSessionRow,
12
- } from "./common.js";
12
+ } from "./common.ts";
13
13
 
14
14
  const SESSION_GRAPH_ROW_SCHEMA = Type.Object({
15
15
  sessionId: Type.String(),
@@ -160,6 +160,13 @@ export function getLineageSessions(
160
160
  return queryRelatedSessions(db, sessionId);
161
161
  }
162
162
 
163
+ export function getLineageRelationMap(
164
+ db: SessionIndexDatabase,
165
+ sessionId: string,
166
+ ): Map<string, SessionLineageRelation> {
167
+ return new Map(getLineageSessions(db, sessionId).map((row) => [row.sessionId, row.relation]));
168
+ }
169
+
163
170
  export function getParentSession(
164
171
  db: SessionIndexDatabase,
165
172
  sessionId: string,
@@ -339,6 +346,7 @@ function queryRelatedSessions(
339
346
  WHERE r.session_id = ?${relationFilter}
340
347
  ORDER BY
341
348
  CASE r.relation
349
+ WHEN 'self' THEN 0
342
350
  WHEN 'parent' THEN 1
343
351
  WHEN 'child' THEN 2
344
352
  WHEN 'sibling' THEN 3
@@ -364,6 +372,13 @@ function collectMaterializedLineageRows(
364
372
  childrenByParent: Map<string, string[]>,
365
373
  ): Map<string, MaterializedLineageRow> {
366
374
  const relations = new Map<string, MaterializedLineageRow>();
375
+ setMaterializedLineageRow(relations, {
376
+ sessionId,
377
+ relatedSessionId: sessionId,
378
+ relation: "self",
379
+ distance: 0,
380
+ });
381
+
367
382
  const visitedAncestors = new Set<string>();
368
383
  const ancestors: Array<{ sessionId: string; distance: number }> = [];
369
384
 
@@ -469,6 +484,8 @@ function setMaterializedLineageRow(
469
484
 
470
485
  function getLineageRelationPriority(relation: SessionLineageRelation): number {
471
486
  switch (relation) {
487
+ case "self":
488
+ return 0;
472
489
  case "parent":
473
490
  return 1;
474
491
  case "child":
@@ -0,0 +1,40 @@
1
+ export interface QueryTermNode {
2
+ kind: "term";
3
+ value: string;
4
+ quoted: boolean;
5
+ prefix: boolean;
6
+ }
7
+
8
+ export interface QueryAndNode {
9
+ kind: "and";
10
+ children: SearchQueryNode[];
11
+ }
12
+
13
+ export interface QueryOrNode {
14
+ kind: "or";
15
+ children: SearchQueryNode[];
16
+ }
17
+
18
+ export interface QueryAdjacencyNode {
19
+ kind: "adjacency";
20
+ children: QueryTermNode[];
21
+ }
22
+
23
+ export interface QueryNotNode {
24
+ kind: "not";
25
+ child: SearchQueryNode;
26
+ }
27
+
28
+ export type SearchQueryNode =
29
+ | QueryTermNode
30
+ | QueryAndNode
31
+ | QueryOrNode
32
+ | QueryAdjacencyNode
33
+ | QueryNotNode;
34
+
35
+ export class SearchQuerySyntaxError extends Error {
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = "SearchQuerySyntaxError";
39
+ }
40
+ }
@@ -0,0 +1,146 @@
1
+ import { type SearchQueryNode, SearchQuerySyntaxError } from "./ast.ts";
2
+ import { parseSearchQuery } from "./parser.ts";
3
+
4
+ export interface CompiledSearchQuery {
5
+ match: string;
6
+ relaxedMatch?: string | undefined;
7
+ excludes: string[];
8
+ positiveTerms: string[];
9
+ }
10
+
11
+ export function compileSearchQuery(input: string): CompiledSearchQuery | undefined {
12
+ const ast = parseSearchQuery(input);
13
+ if (!ast) {
14
+ return undefined;
15
+ }
16
+
17
+ const { positive, excludes } = splitTopLevelExcludes(ast);
18
+ if (!positive) {
19
+ throw new SearchQuerySyntaxError(
20
+ "Negative-only searches are not supported yet. Add at least one positive search term.",
21
+ );
22
+ }
23
+
24
+ validateNoNestedNegation(positive);
25
+ for (const exclude of excludes) {
26
+ validateNoNestedNegation(exclude);
27
+ }
28
+
29
+ const match = compilePositiveNode(positive);
30
+ const relaxedMatch = compilePositiveNode(positive, "OR");
31
+
32
+ return {
33
+ match,
34
+ relaxedMatch: relaxedMatch === match ? undefined : relaxedMatch,
35
+ excludes: excludes.map((exclude) => compilePositiveNode(exclude)),
36
+ positiveTerms: collectPositiveTerms(positive),
37
+ };
38
+ }
39
+
40
+ interface SplitQuery {
41
+ positive: SearchQueryNode | undefined;
42
+ excludes: SearchQueryNode[];
43
+ }
44
+
45
+ function splitTopLevelExcludes(node: SearchQueryNode): SplitQuery {
46
+ if (node.kind === "not") {
47
+ return { positive: undefined, excludes: [node.child] };
48
+ }
49
+
50
+ if (node.kind !== "and") {
51
+ return { positive: node, excludes: [] };
52
+ }
53
+
54
+ const positives: SearchQueryNode[] = [];
55
+ const excludes: SearchQueryNode[] = [];
56
+
57
+ for (const child of flattenAndChildren(node)) {
58
+ if (child.kind === "not") {
59
+ excludes.push(child.child);
60
+ continue;
61
+ }
62
+
63
+ positives.push(child);
64
+ }
65
+
66
+ return {
67
+ positive: buildAndNode(positives),
68
+ excludes,
69
+ };
70
+ }
71
+
72
+ function flattenAndChildren(node: SearchQueryNode): SearchQueryNode[] {
73
+ if (node.kind !== "and") {
74
+ return [node];
75
+ }
76
+
77
+ return node.children.flatMap(flattenAndChildren);
78
+ }
79
+
80
+ function buildAndNode(children: SearchQueryNode[]): SearchQueryNode | undefined {
81
+ if (children.length === 0) {
82
+ return undefined;
83
+ }
84
+
85
+ if (children.length === 1) {
86
+ return children[0];
87
+ }
88
+
89
+ return { kind: "and", children };
90
+ }
91
+
92
+ function validateNoNestedNegation(node: SearchQueryNode): void {
93
+ switch (node.kind) {
94
+ case "not":
95
+ throw new SearchQuerySyntaxError(
96
+ "Negation is only supported as a top-level AND clause in this release.",
97
+ );
98
+ case "and":
99
+ case "or":
100
+ case "adjacency":
101
+ for (const child of node.children) {
102
+ validateNoNestedNegation(child);
103
+ }
104
+ return;
105
+ case "term":
106
+ return;
107
+ }
108
+ }
109
+
110
+ function compilePositiveNode(node: SearchQueryNode, adjacencyOp: "AND" | "OR" = "AND"): string {
111
+ switch (node.kind) {
112
+ case "term":
113
+ return compileTerm(node.value, node.prefix);
114
+ case "and":
115
+ return `(${node.children.map((child) => compilePositiveNode(child, adjacencyOp)).join(" AND ")})`;
116
+ case "or":
117
+ return `(${node.children.map((child) => compilePositiveNode(child, adjacencyOp)).join(" OR ")})`;
118
+ case "adjacency":
119
+ return `(${node.children.map((child) => compileTerm(child.value, child.prefix)).join(` ${adjacencyOp} `)})`;
120
+ case "not":
121
+ throw new SearchQuerySyntaxError(
122
+ "Negation is only supported as a top-level AND clause in this release.",
123
+ );
124
+ }
125
+ }
126
+
127
+ function compileTerm(value: string, prefix: boolean): string {
128
+ return `${quoteFtsValue(value)}${prefix ? "*" : ""}`;
129
+ }
130
+
131
+ function quoteFtsValue(value: string): string {
132
+ return `"${value.replace(/"/g, '""')}"`;
133
+ }
134
+
135
+ function collectPositiveTerms(node: SearchQueryNode): string[] {
136
+ switch (node.kind) {
137
+ case "term":
138
+ return [node.value];
139
+ case "and":
140
+ case "or":
141
+ case "adjacency":
142
+ return node.children.flatMap(collectPositiveTerms);
143
+ case "not":
144
+ return [];
145
+ }
146
+ }
@@ -0,0 +1,140 @@
1
+ import { SearchQuerySyntaxError } from "./ast.ts";
2
+
3
+ export type QueryToken =
4
+ | { kind: "term"; value: string; quoted: boolean; prefix: boolean; offset: number }
5
+ | { kind: "and"; offset: number }
6
+ | { kind: "or"; offset: number }
7
+ | { kind: "not"; offset: number }
8
+ | { kind: "leftParen"; offset: number }
9
+ | { kind: "rightParen"; offset: number };
10
+
11
+ export function lexSearchQuery(input: string): QueryToken[] {
12
+ const tokens: QueryToken[] = [];
13
+ let index = 0;
14
+
15
+ while (index < input.length) {
16
+ const char = input[index];
17
+ if (isWhitespace(char)) {
18
+ index += 1;
19
+ continue;
20
+ }
21
+
22
+ if (char === "(") {
23
+ tokens.push({ kind: "leftParen", offset: index });
24
+ index += 1;
25
+ continue;
26
+ }
27
+
28
+ if (char === ")") {
29
+ tokens.push({ kind: "rightParen", offset: index });
30
+ index += 1;
31
+ continue;
32
+ }
33
+
34
+ if (char === '"') {
35
+ const token = readQuotedTerm(input, index);
36
+ tokens.push(token.token);
37
+ index = token.nextIndex;
38
+ continue;
39
+ }
40
+
41
+ if (char === "-" && isNegationBoundary(input, index)) {
42
+ tokens.push({ kind: "not", offset: index });
43
+ index += 1;
44
+ continue;
45
+ }
46
+
47
+ const token = readUnquotedToken(input, index);
48
+ tokens.push(token.token);
49
+ index = token.nextIndex;
50
+ }
51
+
52
+ return tokens;
53
+ }
54
+
55
+ function readQuotedTerm(input: string, start: number): { token: QueryToken; nextIndex: number } {
56
+ let index = start + 1;
57
+ let value = "";
58
+
59
+ while (index < input.length) {
60
+ const char = input[index];
61
+ if (char === '"') {
62
+ const afterQuote = index + 1;
63
+ const hasPrefix = input[afterQuote] === "*";
64
+ if (value.length === 0) {
65
+ throw new SearchQuerySyntaxError(`Empty quoted term at offset ${start}`);
66
+ }
67
+ return {
68
+ token: { kind: "term", value, quoted: true, prefix: hasPrefix, offset: start },
69
+ nextIndex: hasPrefix ? afterQuote + 1 : afterQuote,
70
+ };
71
+ }
72
+
73
+ value += char;
74
+ index += 1;
75
+ }
76
+
77
+ throw new SearchQuerySyntaxError(`Unclosed quote at offset ${start}`);
78
+ }
79
+
80
+ function readUnquotedToken(input: string, start: number): { token: QueryToken; nextIndex: number } {
81
+ let index = start;
82
+ let value = "";
83
+
84
+ while (index < input.length) {
85
+ const char = input[index];
86
+ if (isWhitespace(char) || char === "(" || char === ")" || char === '"') {
87
+ break;
88
+ }
89
+
90
+ value += char;
91
+ index += 1;
92
+ }
93
+
94
+ if (value.length === 0) {
95
+ throw new SearchQuerySyntaxError(`Unexpected character at offset ${start}`);
96
+ }
97
+
98
+ const upper = value.toUpperCase();
99
+ if (upper === "AND") {
100
+ return { token: { kind: "and", offset: start }, nextIndex: index };
101
+ }
102
+ if (upper === "OR") {
103
+ return { token: { kind: "or", offset: start }, nextIndex: index };
104
+ }
105
+ if (upper === "NOT") {
106
+ return { token: { kind: "not", offset: start }, nextIndex: index };
107
+ }
108
+
109
+ const firstStar = value.indexOf("*");
110
+ if (firstStar >= 0 && firstStar !== value.length - 1) {
111
+ throw new SearchQuerySyntaxError(`Only trailing * is supported at offset ${start}`);
112
+ }
113
+
114
+ const hasPrefix = value.endsWith("*");
115
+ const termValue = hasPrefix ? value.slice(0, -1) : value;
116
+ if (termValue.length === 0) {
117
+ throw new SearchQuerySyntaxError(`Empty prefix term at offset ${start}`);
118
+ }
119
+
120
+ return {
121
+ token: { kind: "term", value: termValue, quoted: false, prefix: true, offset: start },
122
+ nextIndex: index,
123
+ };
124
+ }
125
+
126
+ function isNegationBoundary(input: string, index: number): boolean {
127
+ if (index > 0) {
128
+ const previous = input[index - 1];
129
+ if (!isWhitespace(previous) && previous !== "(") {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ const next = input[index + 1];
135
+ return next !== undefined && !isWhitespace(next) && next !== ")";
136
+ }
137
+
138
+ function isWhitespace(value: string | undefined): boolean {
139
+ return value !== undefined && /\s/.test(value);
140
+ }